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.
18 KiB
Research: @alkdev/operations Package Extraction
Status: COMPLETED — This extraction is done. The
@alkdev/operationspackage (v0.1.0) is published on npm and includes all functionality described here plus the call protocol (PendingRequestMap, ResponseEnvelope, access control, SchemaAdapter). Seedocs/reviews/core-library-extraction-sync-2026-05-18.mdfor 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.errorback to waiting callers call(operationId, input, options?) => Promise<unknown>— publishescall.requested, resolves oncall.respondedsubscribe() => AsyncIterable<CallEventMapValue>— for subscription consumption (stays open, yields events untilcall.abortedorcall.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.requestedevents - Checks
AccessControlagainstIdentity - Executes via
registry.execute()on success - Dispatches
call.respondedon success,call.erroron failure - Uses
mapErroragainsterrorSchemasfor domain error matching
4. Subscription Support
Currently broken/incomplete:
OperationType.SUBSCRIPTIONis defined butregistry.execute()treats it the same as QUERY/MUTATIONSubscriptionHandlertype exists (returnsAsyncGenerator) but no execution path handles itbuildEnvexplicitly filters out SUBSCRIPTION operations — there's nosubscribe()equivalentOperationContext.pubsubis typed asunknownOperationContext.streamis defined but never populated
The fix: call ≡ subscribe means:
call= publishcall.requested, resolvePromiseon firstcall.respondedsubscribe= publishcall.requested, yieldAsyncIterableofcall.respondedevents untilcall.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.SUBSCRIPTIONexists as a type butregistry.execute()callshandler()genericallybuildEnvfilters out SUBSCRIPTION operations with no alternative- No
subscribe()method anywhere OperationContext.pubsubisunknownPendingRequestMapis just an interface withcall()
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()returnsPromise<unknown>— subscribes tocall.responded:{requestId}, resolves on first event, unsubscribessubscribe()returnsAsyncIterable<unknown>— subscribes tocall.responded:{requestId}, yields each event, stays open untilcall.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:
- Looks up the operation (must be SUBSCRIPTION type)
- Creates an
AbortControllerand passes it viacontext.stream - Populates
context.pubsubwith a scoped pubsub instance - Calls the
SubscriptionHandlerand returns theAsyncGenerator
Migration Steps
Phase 1: Decouple and set up package skeleton
- Create
@alkdev/operationsrepo (or directory in monorepo) - Set up build pipeline (tsup, package.json, tsconfig) — same pattern as
@alkdev/taskgraph - Replace logger wrapper —
import { getLogger } from "@logtape/logtape"directly - Inject
Deno.envinfrom_openapi.ts— pass auth token explicitly or via resolver function - Make scanner Deno/Node agnostic — accept injected
readDirandimportModulefunctions, with Deno defaults - Move MCP module from
core/mcp/tosrc/from_mcp.ts— it's an operations adapter, same package - Add
@alkdev/pubsubas dependency — needed forPendingRequestMapimplementation - Write missing tests:
from_schema,from_openapi,from_mcp
Phase 2: Implement call protocol (the missing core)
- Implement
CallEventMapas TypeBox schemas incall-events.ts - Implement
PendingRequestMapclass inenv.ts(replacing the interface):- Constructor takes
PubSubConfig<CallEventMap> - Auto-wires subscriptions for
call.responded,call.aborted,call.error call()returns Promise, resolves on first responsesubscribe()returns AsyncIterable, yields each response until abort/error- Deadline timeout support
- Constructor takes
- Implement
CallHandler— subscribes tocall.requested, validates access, executes, dispatches response/error - Implement
mapError— matches thrown errors againsterrorSchemas, falls back to infrastructure codes - Implement
OperationRegistry.subscribe()— execute SUBSCRIPTION operations, return AsyncIterable via context.stream/context.pubsub - Extend
buildEnv— add callMap mode for SUBSCRIPTION operations (callMap.subscribe instead of callMap.call) - Write tests:
call-protocol.test.ts,subscribe.test.ts
Phase 3: SSE handler and polish
- Fix
from_openapi.tsSSE handler — generate async generator for SUBSCRIPTION operations with SSE parsing - Add
from_openapi.test.ts— OpenAPI spec conversion tests - Publish v0.1.0 to npm
Phase 4: Integration back into alkhub_ts
- Replace
packages/core/operations/andpackages/core/mcp/with@alkdev/operationsdependency - Update
packages/core/deno.jsonandpackages/core/mod.tsto import from@alkdev/operations - Update hub and spoke to use
PendingRequestMap,CallHandler,buildEnvfrom the package - Implement hub-side WebSocket handling — per-connection
WebSocketEventTarget+PendingRequestMapper spoke
Open Questions
-
buildEnvAPI for subscriptions: ShouldbuildEnvreturn two objects ({ call: OperationEnv, subscribe: SubscriptionEnv }) or should it be a single env where SUBSCRIPTION operations have a different signature (returningAsyncIterableinstead ofPromise)? The latter keeps the env shape consistent but complicates typing. The former is more explicit. -
Scanner Deno/Node compatibility: Should
scanner.tsprovide dual implementations (scanOperationsfor Deno withDeno.readDir,scanOperationsNodefor Node withfs.readdir), or inject the filesystem dependency? Injection is cleaner but more verbose for the common case. -
Call graph storage (
graphology): Should@alkdev/operationsinclude call graph tracking (usinggraphology), 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. -
@alkdev/pubsubversion coupling:PendingRequestMapdepends oncreatePubSubandTypedEventTargetfrom@alkdev/pubsub. Should operations pin to exact pubsub versions or use caret ranges? Since both are@alkdevpackages we control, caret ranges should be fine, but breaking changes to theTypedEventTargetinterface would cascade. -
buildEnvdirect mode subscriptions: In direct mode (no callMap), how do subscriptions work? The registry needs asubscribe()method that returnsAsyncIterablefor SUBSCRIPTION operations. This requires the registry to know about the subscription handler type. Currentlyexecute()just callshandler()generically. -
Logger configuration: logtape's
configure()is async and sets up sinks. Should each@alkdevpackage just usegetLogger()and trust that the application has calledconfigure(), or should packages have a setup function? Recommendation: trust the application. logtape logs to a default sink if unconfigured.