Files
hub/docs/research/migration/completed/operations.md
glm-5.1 2b63cda1c7 Setup repo: migrate architecture specs, code stubs, and tasks from alkhub_ts
Copy architecture docs, ADRs, storage domain specs, research, reviews,
and 56 storage architecture tasks from the alkhub_ts monorepo. Adapt for
standalone @alkdev/hub repo structure (src/ not packages/hub/).

Sanitize all sensitive information:
- Replace private IPs (10.0.0.1) with localhost defaults
- Remove internal server hostnames (dev1, ns528096)
- Replace /workspace/ private paths with npm package references
- Remove hardcoded credentials from examples
- Rewrite infrastructure.md without private network details

Add Deno project scaffolding: deno.json (pinned deps), .gitignore,
AGENTS.md, entry point. Migrate existing code stubs (crypto, config
types, logger) with updated import paths.
2026-05-25 10:56:32 +00:00

18 KiB

Research: @alkdev/operations Package Extraction

Status: COMPLETED — This extraction is done. The @alkdev/operations package (v0.1.0) is published on npm and includes all functionality described here plus the call protocol (PendingRequestMap, ResponseEnvelope, access control, SchemaAdapter). See docs/reviews/core-library-extraction-sync-2026-05-18.md for the migration impact analysis.

Goal

Extract packages/core/operations/ and packages/core/mcp/ into a standalone @alkdev/operations package that includes the call protocol (PendingRequestMap, CallHandler, call event types). The call protocol is not a separate module — call ≡ subscribe at the protocol level, so it belongs in the operations package. MCP is an operations adapter, not a separate concern.

Current State

Source: packages/core/operations/

File Lines Key Exports Dependencies
types.ts 212 OperationType, Identity, OperationEnv, OperationContext (TypeBox + type), ErrorDefinition, AccessControl, OperationHandler, SubscriptionHandler, OperationDefinition (TypeBox schema), OperationSpec, IOperationDefinition, OperationSpecSchema @alkdev/typebox
registry.ts 82 OperationRegistry (register, get, list, execute, getSpec, getAllSpecs) @alkdev/typebox/value, ../logger/mod.ts, ./validation.ts, ./types.ts
validation.ts 115 assertIsSchema, validateOrThrow, collectErrors, formatValueErrors @alkdev/typebox, @alkdev/typebox/value, @std/assert
env.ts 83 buildEnv, EnvOptions, PendingRequestMap (interface only) ./types.ts, ./registry.ts, ../logger/mod.ts
scanner.ts 89 scanOperations, OperationManifest @std/path, ./types.ts, ./validation.ts, ../logger/mod.ts, Deno.readDir, Deno.cwd
from_schema.ts 115 FromSchema (JSON Schema → TypeBox converter) @alkdev/typebox
from_openapi.ts 333 FromOpenAPI, FromOpenAPIFile, FromOpenAPIUrl, OpenAPISpec, HTTPServiceConfig @alkdev/typebox, ./from_schema.ts, ./types.ts, Deno.env.get

Source: packages/core/mcp/

File Lines Key Exports Dependencies
wrapper.ts 88 createMCPClient, closeMCPClient, MCPClientWrapper @modelcontextprotocol/sdk, ./../operations/mod.ts, ./../logger/mod.ts, @alkdev/typebox
loader.ts 59 MCPClientLoader ./wrapper.ts, ./../operations/mod.ts, ./../logger/mod.ts
mod.ts 2 Re-exports ./wrapper.ts, ./loader.ts

Test Coverage

Test File Tests What it covers
tests/operations/registry.test.ts 7 Registry CRUD, execute, getSpec, buildEnv direct mode, namespace filtering
tests/operations/scanner.test.ts 3 Directory scanning, empty directory, validation of scanned operations
No tests for from_schema.ts, from_openapi.ts, from_mcp (wrapper/loader), validation.ts edge cases, subscription operations, call protocol mode

Cross-Module Dependencies (Must Be Decoupled)

Dependency Used In Current Import Extraction Strategy
Logger registry.ts, env.ts, scanner.ts ../logger/mod.ts Use @logtape/logtape directly (import { getLogger } from "@logtape/logtape"). Delete the wrapper. Configure sinks at the application level (hub/spoke entry point).
Deno.env.get() from_openapi.ts line 67 Deno.env.get("BEARER_TOKEN") Inject auth resolution via HTTPServiceConfig.auth.resolveToken?(): Promise<string> or make the caller pass the token explicitly.
Deno.readDir(), Deno.cwd() scanner.ts Filesystem discovery Accept as injectable dependency: scanOperations(dirPath, { readDir?, cwd? }), or document as Deno-specific and provide a Node-compatible alternative (e.g., fs.readdir).
MCP ↔ Operations mcp/wrapper.ts ../operations/mod.ts MCP stays in the same package. It's an adapter that wraps MCP tools as operations.
MCP ↔ Logger mcp/wrapper.ts, mcp/loader.ts ../logger/mod.ts Same as operations: use logtape directly.

What Must Be Built (Not Yet in Code)

The call protocol is a core part of operations, not a separate package. It must be implemented for the system to work correctly, especially for subscriptions.

1. Call Event Types (CallEventMap)

Defined in call-graph.md but not implemented. These are TypeBox schemas:

call.requested   { requestId, operationId, input, parentRequestId?, deadline?, identity? }
call.responded   { requestId, output }
call.aborted     { requestId }
call.error       { requestId, code, message, details? }

2. PendingRequestMap

The current env.ts has only the PendingRequestMap interface (3 methods). The full class must:

  • Hold Map<string, CallRequest> for in-flight requests
  • Take PubSubConfig<CallEventMapValue> on construction
  • Auto-wire subscriptions to route call.responded/call.aborted/call.error back to waiting callers
  • call(operationId, input, options?) => Promise<unknown> — publishes call.requested, resolves on call.responded
  • subscribe() => AsyncIterable<CallEventMapValue> — for subscription consumption (stays open, yields events until call.aborted or call.error)
  • Deadline timeout support — auto-abort on timeout

This is the key missing piece that makes subscriptions work. Without it, buildEnv can't route calls through the event system, and there's no way to consume subscription operations.

3. CallHandler

buildCallHandler(registry, eventTarget) that:

  • Subscribes to call.requested events
  • Checks AccessControl against Identity
  • Executes via registry.execute() on success
  • Dispatches call.responded on success, call.error on failure
  • Uses mapError against errorSchemas for domain error matching

4. Subscription Support

Currently broken/incomplete:

  • OperationType.SUBSCRIPTION is defined but registry.execute() treats it the same as QUERY/MUTATION
  • SubscriptionHandler type exists (returns AsyncGenerator) but no execution path handles it
  • buildEnv explicitly filters out SUBSCRIPTION operations — there's no subscribe() equivalent
  • OperationContext.pubsub is typed as unknown
  • OperationContext.stream is defined but never populated

The fix: call ≡ subscribe means:

  • call = publish call.requested, resolve Promise on first call.responded
  • subscribe = publish call.requested, yield AsyncIterable of call.responded events until call.aborted
  • Same event types, same PendingRequestMap, different consumption pattern

5. Error Model

mapError function and CallError codes (OPERATION_NOT_FOUND, ACCESS_DENIED, VALIDATION_ERROR, TIMEOUT, ABORTED, EXECUTION_ERROR, UNKNOWN_ERROR) are spec'd but not implemented. Used by CallHandler to produce structured errors.

6. SSE Handler Fix for FromOpenAPI

from_openapi.ts detects SSE endpoints but doesn't generate async generator handlers. The handler needs to stream SSE events for SUBSCRIPTION operations instead of doing a one-shot fetch.

Proposed Package Structure

@alkdev/operations/
  src/
    index.ts                        # Barrel: re-exports all public API
    
    # Core (always included)
    types.ts                        # OperationType, IOperationDefinition, OperationContext, etc.
    registry.ts                     # OperationRegistry class
    validation.ts                   # assertIsSchema, validateOrThrow, collectErrors
    env.ts                          # buildEnv, PendingRequestMap (interface + full class), CallHandler
    call-events.ts                  # CallEventMap TypeBox schemas, error codes
    error-map.ts                    # mapError function, CallError type, infrastructure error codes
    
    # Adapters (tree-shakeable, peer deps isolated)
    from_schema.ts                  # JSON Schema → TypeBox converter (peer: @alkdev/typebox)
    from_openapi.ts                 # OpenAPI spec → operations (peer: none beyond core)
    from_mcp.ts                     # MCP tools → operations (peer: @modelcontextprotocol/sdk)
    scanner.ts                      # Local TS file discovery (peer: Deno runtime OR injected fs)
    
    # Subscription support
    subscribe.ts                    # subscribe() for SUBSCRIPTION operations, AsyncIterable handling
    
  tests/
    registry.test.ts               # Existing + subscription tests
    call-protocol.test.ts          # PendingRequestMap, CallHandler, call/respond/abort flow
    from_schema.test.ts            # JSON Schema conversion
    from_openapi.test.ts           # OpenAPI spec handling
    from_mcp.test.ts                # MCP client wrapper/loader
    subscribe.test.ts               # AsyncIterable subscription flow
    env.test.ts                     # buildEnv with callMap, namespace filtering, subscription filtering
    
  package.json
  tsconfig.json

Adapter Peer Dependencies (following typemap pattern)

Adapter Module Peer Dependencies Notes
from_schema.ts @alkdev/typebox (already a core dep) No extra peer
from_openapi.ts None beyond core Auth token resolution injected (no Deno.env)
from_mcp.ts @modelcontextprotocol/sdk Only loaded when you import from_mcp. Tree-shakeable.
scanner.ts @std/path (or inject fs) Deno runtime for Deno.readDir. Could accept injected readDir + import functions for Node compat.

Dependencies

Dependency Type Notes
@alkdev/typebox direct Core schema engine. Used everywhere.
@alkdev/typebox/value direct Value.Check, Value.Errors, Value.Hash for validation.
@alkdev/pubsub direct createPubSub, TypedEventTarget for call protocol event routing. PendingRequestMap depends on this.
@logtape/logtape direct Replace ../logger/mod.ts wrapper with direct import { getLogger } from "@logtape/logtape". Zero-dep logger, consistent across packages.
@std/assert direct Used in validation.ts for assertIsSchema.
@std/path peer Used by scanner.ts for path resolution.
@modelcontextprotocol/sdk peer Only imported by from_mcp.ts. Tree-shakeable.
graphology direct (future) For call graph and operation graph. Not yet in deno.json. Needed for call graph tracking.

Logger Strategy

The current packages/core/logger/mod.ts is 27 lines — just configure() and getLogger() wrapping logtape. For the extracted package:

Option A: Direct logtape import (recommended)

  • Each module does import { getLogger } from "@logtape/logtape"
  • configure() stays in the application entry point (hub/spoke)
  • Zero duplication, zero coupling
  • logtape is already a direct dependency, not going through a wrapper

Option B: @alkdev/logger package

  • Create a tiny shared logger config package
  • Adds a package dependency for 27 lines
  • Only justified if the config pattern is complex enough to warrant sharing

logtape's getLogger("category") is the same pattern used in the current wrapper. Option A is effectively what we're already doing, minus the unnecessary indirection of ../logger/mod.ts.

The Call ≡ Subscribe Contract

This is the central design decision for the package. Here's how it works in detail:

Current State (Broken)

  • OperationType.SUBSCRIPTION exists as a type but registry.execute() calls handler() generically
  • buildEnv filters out SUBSCRIPTION operations with no alternative
  • No subscribe() method anywhere
  • OperationContext.pubsub is unknown
  • PendingRequestMap is just an interface with call()

Target State

Same event types for both calls and subscriptions:

QUERY/MUTATION:
  caller → call.requested → [event system] → call.responded → caller (resolve Promise)

SUBSCRIPTION:
  caller → call.requested → [event system] → call.responded → caller (yield first)
                                                  → call.responded → caller (yield next)
                                                  → call.responded → caller (yield next)
                                                  → call.aborted    → caller (done)

PendingRequestMap handles both:

  • call() returns Promise<unknown> — subscribes to call.responded:{requestId}, resolves on first event, unsubscribes
  • subscribe() returns AsyncIterable<unknown> — subscribes to call.responded:{requestId}, yields each event, stays open until call.aborted

buildEnv gets extended:

  • Direct mode: registry.execute() for QUERY/MUTATION, registry.subscribe() for SUBSCRIPTION
  • Call protocol mode: callMap.call() for QUERY/MUTATION, callMap.subscribe() for SUBSCRIPTION

The OperationRegistry needs a subscribe() method that:

  1. Looks up the operation (must be SUBSCRIPTION type)
  2. Creates an AbortController and passes it via context.stream
  3. Populates context.pubsub with a scoped pubsub instance
  4. Calls the SubscriptionHandler and returns the AsyncGenerator

Migration Steps

Phase 1: Decouple and set up package skeleton

  1. Create @alkdev/operations repo (or directory in monorepo)
  2. Set up build pipeline (tsup, package.json, tsconfig) — same pattern as @alkdev/taskgraph
  3. Replace logger wrapperimport { getLogger } from "@logtape/logtape" directly
  4. Inject Deno.env in from_openapi.ts — pass auth token explicitly or via resolver function
  5. Make scanner Deno/Node agnostic — accept injected readDir and importModule functions, with Deno defaults
  6. Move MCP module from core/mcp/ to src/from_mcp.ts — it's an operations adapter, same package
  7. Add @alkdev/pubsub as dependency — needed for PendingRequestMap implementation
  8. Write missing tests: from_schema, from_openapi, from_mcp

Phase 2: Implement call protocol (the missing core)

  1. Implement CallEventMap as TypeBox schemas in call-events.ts
  2. Implement PendingRequestMap class in env.ts (replacing the interface):
    • Constructor takes PubSubConfig<CallEventMap>
    • Auto-wires subscriptions for call.responded, call.aborted, call.error
    • call() returns Promise, resolves on first response
    • subscribe() returns AsyncIterable, yields each response until abort/error
    • Deadline timeout support
  3. Implement CallHandler — subscribes to call.requested, validates access, executes, dispatches response/error
  4. Implement mapError — matches thrown errors against errorSchemas, falls back to infrastructure codes
  5. Implement OperationRegistry.subscribe() — execute SUBSCRIPTION operations, return AsyncIterable via context.stream/context.pubsub
  6. Extend buildEnv — add callMap mode for SUBSCRIPTION operations (callMap.subscribe instead of callMap.call)
  7. Write tests: call-protocol.test.ts, subscribe.test.ts

Phase 3: SSE handler and polish

  1. Fix from_openapi.ts SSE handler — generate async generator for SUBSCRIPTION operations with SSE parsing
  2. Add from_openapi.test.ts — OpenAPI spec conversion tests
  3. Publish v0.1.0 to npm

Phase 4: Integration back into alkhub_ts

  1. Replace packages/core/operations/ and packages/core/mcp/ with @alkdev/operations dependency
  2. Update packages/core/deno.json and packages/core/mod.ts to import from @alkdev/operations
  3. Update hub and spoke to use PendingRequestMap, CallHandler, buildEnv from the package
  4. Implement hub-side WebSocket handling — per-connection WebSocketEventTarget + PendingRequestMap per spoke

Open Questions

  1. buildEnv API for subscriptions: Should buildEnv return two objects ({ call: OperationEnv, subscribe: SubscriptionEnv }) or should it be a single env where SUBSCRIPTION operations have a different signature (returning AsyncIterable instead of Promise)? The latter keeps the env shape consistent but complicates typing. The former is more explicit.

  2. Scanner Deno/Node compatibility: Should scanner.ts provide dual implementations (scanOperations for Deno with Deno.readDir, scanOperationsNode for Node with fs.readdir), or inject the filesystem dependency? Injection is cleaner but more verbose for the common case.

  3. Call graph storage (graphology): Should @alkdev/operations include call graph tracking (using graphology), or should that be a hub-level concern? The graph is populated as a side effect of the call protocol, but storage (Postgres) is a hub concern. Recommendation: graph tracking in operations, storage in hub.

  4. @alkdev/pubsub version coupling: PendingRequestMap depends on createPubSub and TypedEventTarget from @alkdev/pubsub. Should operations pin to exact pubsub versions or use caret ranges? Since both are @alkdev packages we control, caret ranges should be fine, but breaking changes to the TypedEventTarget interface would cascade.

  5. buildEnv direct mode subscriptions: In direct mode (no callMap), how do subscriptions work? The registry needs a subscribe() method that returns AsyncIterable for SUBSCRIPTION operations. This requires the registry to know about the subscription handler type. Currently execute() just calls handler() generically.

  6. Logger configuration: logtape's configure() is async and sets up sinks. Should each @alkdev package just use getLogger() and trust that the application has called configure(), or should packages have a setup function? Recommendation: trust the application. logtape logs to a default sink if unconfigured.