@sguild/dispatcher
v2.0.2
Published
Cross-domain event dispatcher SDK for Sguild domains, per the Sguild Event Envelope contract and ADR-0009. Producer-side transactional emit, consumer-side polling worker with dedup/retry/dead-letter, envelope and payload validation against the bundled con
Readme
Dispatcher SDK
Cross-domain event dispatcher per the Sguild Event Envelope contract
(coordination/contracts/event-envelope/README.md, currently v1.0.2).
This module is the runtime that producers and consumers call into to emit and subscribe to cross-domain events. It owns envelope construction, payload validation, dedup, and transport.
It is published as the @sguild/dispatcher npm package so every domain
repo consumes one shared, versioned runtime rather than vendoring a copy.
The package is built and published from this directory in the platform
repo; see "Installing and consuming" and "Where the SDK code lives" below.
Status: Phase 3 landed in repo
Per the Q2 Airtable sunset directive
(coordination/memos/2026/2026-05-09-platform-q2-airtable-sunset-directive.md)
the dispatcher SDK ship dates compressed against the original build plan.
Current state:
Phase 0 (foundation): complete. The public types, the registry
loader, the dispatcher API surface, and 22 unit tests landed 2026-05-02.
Producers and consumers compile against the surface; the runtime methods
still throw DispatcherNotImplementedError until Phase 2 Slice 2 wires
the transport implementation in.
Phase 1 (in-process dispatcher): deferred indefinitely. All five
non-Platform domains explicitly endorsed the Phase 0 + Phase 2 (bus-only)
shape per the build-plan asks closure
(coordination/memos/2026/2026-05-02-platform-dispatcher-sdk-build-plan-asks-closed.md).
Reopening requires a separate proposal memo on the build plan thread.
Phase 2 (bus dispatcher): complete. ADR-0009 accepted 2026-05-02
at Postgres-backed queue with LISTEN/NOTIFY wake-up
(coordination/adrs/ADR-0009-dispatcher-cross-process-transport.md).
Hard date for full Phase 2 ship: 2026-06-22 per the Q2 directive
(compressed 4 days from the original 2026-06-26 commitment).
Slice 1 (landed earlier 2026-05-09): the schema foundation.
- Four Prisma models:
DispatcherEvent,DispatcherCursor,DispatcherDedup,DispatcherDeadLetter. Seeprisma/schema.prismafor the canonical definitions and the table-by-table comment header. - Raw-SQL migration at
prisma/migrations/20260509190000_dispatcher_phase_2_schema/migration.sqlwith the four tables, six indexes (including the partialdispatcher_dead_letter_active_per_consumerfor the active-only DLQ-read hot path), thedispatcher_event_notifytrigger function, and the AFTER INSERT trigger ondispatcher_eventthat firespg_notify('dispatcher_event_inserted', NEW.event_type)for the consumer-side wake-up primitive. ajvandajv-formatsadded topackage.jsonfor runtime payload validation against the JSON Schemas incoordination/contracts/<contract>/schema/payloads/per ADR-0009 action item 8.
Slice 2 (landed 2026-05-09 alongside Slice 1): the publish path.
lib/dispatcher/config.tscarries the runtime config (producer,tenantId, and the optional injectedprismaClient) every emit needs. Each domain repo's bootstrap callsconfigureDispatcher({ producer, tenantId, prismaClient })once at startup;DISPATCHER_PRODUCERandDISPATCHER_TENANT_IDenv vars are the fallback forproducer/tenantIdin dev-loop and CI.prismaClientis the default client for publishes that do not pass a per-calltx; the SDK keeps@prisma/clientas a peer dependency and never imports a domain's generated client directly.lib/dispatcher/validator.tsships ajv-backed envelope validation againstcontracts/event-envelope/schema/envelope-v1.jsonand per-(event_type, schema_version)payload validation against thepayload_schemapaths registered incontracts/event-types-registry.json. Compiled validators cache on first use.lib/dispatcher/postgres-transport.tsshipspublishToPostgres(emit, options?)with optionaltx: Prisma.TransactionClient. The function resolves the registration and schema version, builds the envelope (auto-populating event_id, occurred_at, tenant_id, producer, schema_version per envelope contract §10.2), validates, and inserts intodispatcher_eventusing the supplied tx (or the default Prisma client if no tx). The same-transaction insert is the producer-transactional-guarantee primitive per ADR-0009 §"Producer transactional guarantee".lib/dispatcher/dispatcher.tspublishno longer throwsDispatcherNotImplementedError; it callspublishToPostgresthrough to the transport. Producers can emit events today against the live dispatcher_event table. Error classes extracted intolib/dispatcher/dispatcher-errors.tsand re-exported fromdispatcher.tsso existing import sites continue to work.
Slice 3 (landed 2026-05-09 alongside Slices 1 and 2): the consumer polling worker.
lib/dispatcher/postgres-consumer.tsshipsConsumerLoop, a long-lived process that pollsdispatcher_eventpast the per-(consumer, event_type) cursor in batches (default 50 per cycle), checksdispatcher_dedupbefore invoking the handler (so a re-dispatched event already delivered to this consumer skips re-invocation), retries handler exceptions with exponential backoff plus 0-30 percent jitter (default 3 retries on top of the initial attempt; delays[1000, 5000, 15000]ms), and dead-letters intodispatcher_dead_letterafter retry exhaustion. Cursor advance and dedup/dead-letter writes run in a single Prisma transaction per row so the at-most-once promise holds across crash points.lib/dispatcher/dispatcher.tssubscribenow registers handlers in the singleton's internal list (instead of throwingDispatcherNotImplementedError). Two new methods:start({ consumer, batchSize?, pollIntervalMs?, retryDelaysMs? })instantiates the ConsumerLoop and runs it;stop()flips the running flag and waits for the in-flight batch to drain. Subscribers typically wireprocess.on("SIGTERM", () => dispatcher.stop())so a deploy rollover drains cleanly.- The barrel
lib/dispatcher/index.tsre-exportsconfigureDispatcher,DispatcherConfig,PublishOptions,ConsumerLoopOptions, and the three validator error classes alongside the existing surface.
Slice 3b (landed 2026-05-09 alongside Slices 1, 2, and 3): LISTEN/NOTIFY wake-up.
lib/dispatcher/postgres-consumer.tsConsumerLoopnow opens a separatepg.Clientconnection (not from the Prisma pool, since LISTEN ties up the connection for its duration) and runsLISTEN dispatcher_event_inserted. Onnotificationevents, the consumer filters on the payload (the inserted row'sevent_type); when a subscribed event_type fires, the polling loop's between-cycle sleep aborts via anAbortControllerand the next batch runs immediately. Typical wake-up latency drops from the configured poll cadence (default 5 seconds) to sub-second on the happy path.- Connection lifecycle: best-effort startup (LISTEN failures don't
block the polling loop, just log and reconnect with backoff per
LISTEN_RECONNECT_DELAYS_MS = [1000, 5000, 15000, 60000]). On the pg.Clienterrororendevents,scheduleListenReconnectqueues a reconnect withsetTimeout(unref'd so it doesn't block process exit). Successful reconnect resets the backoff counter.stop()closes the LISTEN connection cleanly withUNLISTENfollowed byclient.end(). - Polling stays as the durable fallback the whole time. A dropped LISTEN connection degrades wake-up latency to poll cadence but never blocks delivery; missed notifications (LISTEN queue overflow, network blip, reconnect window) get caught by the next poll cycle.
Slice 5 (landed 2026-05-09 alongside Slices 1, 2, 3, and 3b): per-consumer DLQ read and resolve API.
lib/dispatcher/dlq.tsships three service functions:listDeadLetters(consumer, { includeResolved?, limit? })returns the active (or all) dead-letters for a consumer, sorted bycreated_atdescending;getDeadLetter(deadLetterId)returns one row by itsdlq_<UUID>id;resolveDeadLetter(deadLetterId, { resolvedBy, resolutionNote? })marks a row resolved withresolved_at = NOW()and the operator identifier. Resolution is one-shot; throwsDeadLetterAlreadyResolvedErroron a re-resolve attempt.- Three HTTP routes under
/api/dispatcher/v1/dlq/...:GET /api/dispatcher/v1/dlq?consumer=...&include_resolved=...&limit=...lists dead-letters for a consumer.GET /api/dispatcher/v1/dlq/[deadLetterId]returns one row.POST /api/dispatcher/v1/dlq/[deadLetterId]/resolvemarks one resolved; body is{ "resolved_by": "<operator-id>", "resolution_note": "<optional>" }.
- Auth:
requireSessionon all three routes (parallel to the identity routes' v1 surface). Tenant or role-based gating can layer in when the DLQ moves to a superadmin-only surface. - Index re-exports the DLQ surface alongside the rest of the dispatcher
SDK so consumer-domain admin tools can import directly:
import { listDeadLetters, resolveDeadLetter } from "@sguild/dispatcher".
Phase 2 status: complete
All five Phase 2 slices landed. The dispatcher SDK is production-ready on the producer side (transactional emit, envelope and payload validation), the consumer side (polling worker with LISTEN/NOTIFY wake-up, dedup, retry-with-jitter, dead-letter on exhaustion), and the operator surface (DLQ read and resolve API). Phase 3 (docs, observability hooks, Coaching cut-over migration guide, reference implementations) ships by 2026-06-29 per the Q2 directive.
(Slice 4, originally "wire subscribe", landed inside Slice 3 since the wiring was a one-line change once the ConsumerLoop existed.)
Phase 3 (consumer enablement): complete in repo. Observability hooks and the first docs set landed in this slice:
lib/dispatcher/observability.tsexposesconfigureDispatcherObservability.- Publish path increments
dispatcher.publish.count. - Consumer path increments
dispatcher.consume.count,dispatcher.dedup_hit.count, anddispatcher.dead_letter.count. - Consumer path observes
dispatcher.end_to_end_latency_msanddispatcher.handler_latency_ms. - Docs:
docs/dispatcher/phase-3-observability.mddocs/dispatcher/coaching-cutover-guide.mddocs/dispatcher/reference-implementations.mddocs/dispatcher/event-vs-external-actions.md
Consumer fallback
The dispatcher is now the preferred cross-domain event path. Domains may keep synchronous producer API reads as an operational fallback during their cutover window, but new durable cross-domain event consumption should use the SDK so cursoring, dedup, DLQ, and observability all land on the same rail.
Current deliverables
- Public SDK surface:
dispatcher.publish,dispatcher.subscribe,dispatcher.start,dispatcher.stop, config helpers, typed envelopes, registry lookups, and error classes. - Producer path: transactional Postgres insert, envelope construction, envelope validation, payload validation, and LISTEN/NOTIFY wake-up.
- Consumer path: cursoring, dedup, retry with jitter, dead-letter on retry exhaustion, and graceful process drain.
- Operator path: DLQ list, read, and resolve helpers plus HTTP routes.
- Observability path: vendor-neutral counter and histogram hooks for publish, consume, dedup hit, dead-letter, end-to-end latency, and handler latency.
- Enablement docs: Phase 3 metrics wiring, Coaching cutover guide, Revenue emit reference, Coaching projection subscriber reference, dedup escalation for cutover windows, and dispatcher-vs-external-actions guidance.
Per-event-type payload schemas still land with the owning contract as each event_type acquires a binding consumer.
Installing and consuming
The SDK ships as @sguild/dispatcher. A consuming domain adds it as a
dependency and imports from the package root:
import { dispatcher, configureDispatcher } from "@sguild/dispatcher";@prisma/client is a peer dependency. The SDK does not import any
domain's generated Prisma client directly; the consuming domain injects
its own at startup (see "Usage"). Each domain owns its DispatcherEvent,
DispatcherCursor, DispatcherDedup, and DispatcherDeadLetter models
and migration per ADR-0009's per-domain table family.
The event-type registry and the JSON Schemas are bundled inside the
package (contracts/), so payload and envelope validation work at runtime
with no dependency on a sibling coordination repo. registry.ts and
validator.ts resolve the bundled copy relative to the module location;
a checkout that prefers a live coordination repo can still pass an
explicit path to loadRegistry, and a cwd-relative ../coordination/...
fallback is tried last.
Building and publishing happen from this directory:
npm run build # tsc -> dist/ (JS + .d.ts)
npm publish # prepublishOnly runs the build; ships dist/ + contracts/dist/ is git-ignored; it is a build artifact produced by
prepublishOnly. The platform app itself continues to consume the SDK
from source via the @/lib/dispatcher path alias, so the nested
package.json is publish metadata and does not introduce a separate
node_modules for in-repo development.
Where the SDK code lives
The build plan named two options: a new repo
github.com/sguild-admin/dispatcher published to npm as
@sguild/dispatcher, or a module inside the platform repo at
lib/dispatcher/. The resolved decision (see
coordination/memos/2026/2026-05-14-platform-dispatcher-producer-sdk-consumption.md):
the SDK is packaged and published as @sguild/dispatcher from this
directory in the platform repo. Domains consume the published package
rather than vendoring a copy.
The directory is a self-contained, publishable package: its own
package.json (name, exports map, dependencies, @prisma/client peer
dependency) and tsconfig.json (CJS + .d.ts build to dist/). The
public surface in index.ts is the API contract; the internal file
layout is malleable. Extraction to a standalone repo later, if ever
warranted, stays a mechanical move because the package boundary is
already drawn here.
Usage
import { dispatcher, configureDispatcher, type EventEnvelope } from "@sguild/dispatcher";
import { prisma } from "./db/prisma"; // the consuming domain's own client
// Startup: configure once. `prismaClient` is the default client used by
// publishes that do not pass a per-call `tx`. The SDK has @prisma/client
// as a peer dependency and never imports a domain's generated client
// directly, so the domain injects its own here.
configureDispatcher({
producer: "revenue",
tenantId: "tnt_sguild",
prismaClient: prisma,
});
// Producer, transactional: the event row inserts in the SAME transaction
// as the domain write (the producer-transactional-guarantee per ADR-0009).
await prisma.$transaction(async (tx) => {
await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
await dispatcher.publish(
{
event_type: "credit.locked",
payload: {
credit_reservation_id: "crr_...",
lesson_id: "les_...",
person_id: "per_...",
locked_credits: 6,
locked_at: new Date().toISOString(),
},
subject: "per_...",
actor: "system:revenue",
},
{ tx },
);
});
// Producer, no domain write to coordinate with: omit `tx` and the publish
// runs against the injected `prismaClient` in its own transaction.
await dispatcher.publish({
event_type: "credit.locked",
payload: { /* ... */ },
subject: "per_...",
actor: "system:revenue",
});
// Consumer
type CreditLockedPayload = {
credit_reservation_id: string;
lesson_id: string;
person_id: string;
locked_credits: number;
locked_at: string;
};
dispatcher.subscribe<CreditLockedPayload>("credit.locked", async (event) => {
const { lesson_id, locked_at } = event.payload;
// ... Coaching's availability projection update, etc.
});Standards alignment
This module is cross-cutting infrastructure per
coordination/standards/engineering/module-layout.md §1, so it lives at
platform/lib/dispatcher/ rather than platform/modules/dispatcher/.
The per-domain six-file module layout (dto, schema, repo, service,
route, index, optional actions) does not apply; the dispatcher does
not own a domain object. Internal file organization (types, registry,
dispatcher, transport-stubs) is dispatcher-specific.
Related artifacts
- Build plan:
coordination/memos/2026/2026-05-01-platform-dispatcher-sdk-build-plan.md - Gap memo (origin of the SDK conversation):
coordination/memos/2026/2026-05-01-platform-dispatcher-sdk-gap-and-interim-shape.md - Event envelope contract:
coordination/contracts/event-envelope/README.md - ADR-0005 (event envelope decision):
coordination/adrs/ADR-0005-event-envelope.md - ADR-0009 (bus choice; pending): tracked on the build plan thread
- Platform-owed ledger:
coordination/memos/2026/2026-05-01-platform-owed-ledger.md
