Update architecture docs for handler separation and pubsub API changes

- api-surface.md: Updated registry API table (registerSpec, registerHandler,
  getHandler, separated spec/handler storage), OperationSpec description,
  IOperationDefinition marked as convenience type, adapter return types
- call-protocol.md: Added pubsub EventEnvelope unwrapping details,
  subscribe(type, id) 2-arg API, handler separation in buildCallHandler
  and subscribe(), handler separation section
- adapters.md: Updated return types (OperationSpec & { handler }),
  scanner validates against OperationSpecSchema, new module shape examples
  showing spec-only and spec+handler patterns, typemap mention
- README.md: Core principle updated for spec/handler separation
- build-distribution.md: Updated pubsub dep description, registry.ts description
- AGENTS.md: Updated key points, source layout, provenance status
This commit is contained in:
2026-05-09 08:34:41 +00:00
parent 4f11f8e7a0
commit d0017df2bf
6 changed files with 109 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
---
status: draft
last_updated: 2026-04-30
last_updated: 2026-05-09
---
# Call Protocol
@@ -103,6 +103,7 @@ const callMap = new PendingRequestMap(eventTarget?)
- Creates an internal `PubSub<CallPubSubMap>` using `createPubSub`
- If `eventTarget` is provided, passes it to `createPubSub` for transport-level event routing (Redis, WebSocket, etc.)
- Wires subscription handlers for `call.responded`, `call.error`, and `call.aborted` to route events back to waiting callers
- Subscriptions use empty-string id (`subscribe("call.responded", "")`) to receive all events of each type. Events are unwrapped from `EventEnvelope` via `.payload`
### `call(operationId, input, options?)`
@@ -158,13 +159,15 @@ type CallHandler = (event: CallRequestedEvent) => Promise<void>
### Handler Flow
1. Look up operation by `operationId` from the registry
1. Look up spec by `operationId` from the registry via `getSpec()`
2. If not found, throw `CallError(OPERATION_NOT_FOUND, ...)`
3. Check access control (see below)
4. Validate input with `validateOrThrow`
5. Execute operation handler
6. On success: the handler is expected to have published `call.responded` through whatever mechanism
7. On failure: `mapError` converts the thrown value to `CallError`
3. Look up handler by `operationId` via `getHandler()`
4. If not found, throw `CallError(OPERATION_NOT_FOUND, "No handler registered for operation: ...")`
5. Check access control (see below)
6. Validate input with `validateOrThrow`
7. Execute operation handler
8. On success: the handler is expected to have published `call.responded` through whatever mechanism
9. On failure: `mapError` converts the thrown value to `CallError`
The `CallHandler` is designed to be wired into a pubsub subscription:
@@ -280,4 +283,13 @@ async function* subscribe(
Gets the operation from the registry, casts its handler to `AsyncGenerator`, and yields values. Properly cleans up with `generator.return()` in a `finally` block.
Use `subscribe()` for in-process consumption. Use `PendingRequestMap.call()` for cross-transport invocation that resolves after one event. For cross-transport streaming, use `PendingRequestMap.subscribe()` to yield multiple events.
Use `subscribe()` for in-process consumption. Use `PendingRequestMap.call()` for cross-transport invocation that resolves after one event. For cross-transport streaming, use `PendingRequestMap.subscribe()` to yield multiple events.
### Handler Separation
The `subscribe()` function looks up both spec and handler separately from the registry:
1. `registry.getSpec(operationId)` — throws if spec not found
2. `registry.getHandler(operationId)` — throws if handler not found
This allows spec-only registration for scenarios where handlers are provided separately (e.g., ujsx host interpretation, dynamic handler injection).