npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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. See prisma/schema.prisma for the canonical definitions and the table-by-table comment header.
  • Raw-SQL migration at prisma/migrations/20260509190000_dispatcher_phase_2_schema/migration.sql with the four tables, six indexes (including the partial dispatcher_dead_letter_active_per_consumer for the active-only DLQ-read hot path), the dispatcher_event_notify trigger function, and the AFTER INSERT trigger on dispatcher_event that fires pg_notify('dispatcher_event_inserted', NEW.event_type) for the consumer-side wake-up primitive.
  • ajv and ajv-formats added to package.json for runtime payload validation against the JSON Schemas in coordination/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.ts carries the runtime config (producer, tenantId, and the optional injected prismaClient) every emit needs. Each domain repo's bootstrap calls configureDispatcher({ producer, tenantId, prismaClient }) once at startup; DISPATCHER_PRODUCER and DISPATCHER_TENANT_ID env vars are the fallback for producer/tenantId in dev-loop and CI. prismaClient is the default client for publishes that do not pass a per-call tx; the SDK keeps @prisma/client as a peer dependency and never imports a domain's generated client directly.
  • lib/dispatcher/validator.ts ships ajv-backed envelope validation against contracts/event-envelope/schema/envelope-v1.json and per-(event_type, schema_version) payload validation against the payload_schema paths registered in contracts/event-types-registry.json. Compiled validators cache on first use.
  • lib/dispatcher/postgres-transport.ts ships publishToPostgres(emit, options?) with optional tx: 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 into dispatcher_event using 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.ts publish no longer throws DispatcherNotImplementedError; it calls publishToPostgres through to the transport. Producers can emit events today against the live dispatcher_event table. Error classes extracted into lib/dispatcher/dispatcher-errors.ts and re-exported from dispatcher.ts so 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.ts ships ConsumerLoop, a long-lived process that polls dispatcher_event past the per-(consumer, event_type) cursor in batches (default 50 per cycle), checks dispatcher_dedup before 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 into dispatcher_dead_letter after 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.ts subscribe now registers handlers in the singleton's internal list (instead of throwing DispatcherNotImplementedError). 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 wire process.on("SIGTERM", () => dispatcher.stop()) so a deploy rollover drains cleanly.
  • The barrel lib/dispatcher/index.ts re-exports configureDispatcher, 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.ts ConsumerLoop now opens a separate pg.Client connection (not from the Prisma pool, since LISTEN ties up the connection for its duration) and runs LISTEN dispatcher_event_inserted. On notification events, the consumer filters on the payload (the inserted row's event_type); when a subscribed event_type fires, the polling loop's between-cycle sleep aborts via an AbortController and 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.Client error or end events, scheduleListenReconnect queues a reconnect with setTimeout (unref'd so it doesn't block process exit). Successful reconnect resets the backoff counter. stop() closes the LISTEN connection cleanly with UNLISTEN followed by client.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.ts ships three service functions: listDeadLetters(consumer, { includeResolved?, limit? }) returns the active (or all) dead-letters for a consumer, sorted by created_at descending; getDeadLetter(deadLetterId) returns one row by its dlq_<UUID> id; resolveDeadLetter(deadLetterId, { resolvedBy, resolutionNote? }) marks a row resolved with resolved_at = NOW() and the operator identifier. Resolution is one-shot; throws DeadLetterAlreadyResolvedError on 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]/resolve marks one resolved; body is { "resolved_by": "<operator-id>", "resolution_note": "<optional>" }.
  • Auth: requireSession on 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.ts exposes configureDispatcherObservability.
  • Publish path increments dispatcher.publish.count.
  • Consumer path increments dispatcher.consume.count, dispatcher.dedup_hit.count, and dispatcher.dead_letter.count.
  • Consumer path observes dispatcher.end_to_end_latency_ms and dispatcher.handler_latency_ms.
  • Docs:
    • docs/dispatcher/phase-3-observability.md
    • docs/dispatcher/coaching-cutover-guide.md
    • docs/dispatcher/reference-implementations.md
    • docs/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