@sguild/dispatcher 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +354 -0
  2. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json +59 -0
  3. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json +80 -0
  4. package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json +74 -0
  5. package/contracts/credit-reservation-lock/schema/payloads/credit.consumed-v1.json +33 -0
  6. package/contracts/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json +41 -0
  7. package/contracts/credit-reservation-lock/schema/payloads/credit.funded-v1.json +31 -0
  8. package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +42 -0
  9. package/contracts/credit-reservation-lock/schema/payloads/credit.purchased-v1.json +39 -0
  10. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v1.json +61 -0
  11. package/contracts/credit-reservation-lock/schema/payloads/credit.released-v2.json +77 -0
  12. package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +60 -0
  13. package/contracts/credit-reservation-lock/schema/payloads/customer.handoff-v1.json +35 -0
  14. package/contracts/event-envelope/schema/envelope-v1.json +79 -0
  15. package/contracts/event-types-registry.json +541 -0
  16. package/contracts/identity/schema/payloads/intake.amended-v1.json +124 -0
  17. package/contracts/identity/schema/payloads/intake.captured-v2.json +114 -0
  18. package/contracts/identity/schema/payloads/person.updated-v1.json +99 -0
  19. package/contracts/lead-lifecycle/schema/payloads/lead.attempt.exhausted-v1.json +36 -0
  20. package/contracts/lead-lifecycle/schema/payloads/lead.callback.scheduled-v1.json +39 -0
  21. package/contracts/lead-lifecycle/schema/payloads/lead.created-v1.json +50 -0
  22. package/contracts/lead-lifecycle/schema/payloads/lead.reached-v1.json +39 -0
  23. package/contracts/lead-lifecycle/schema/payloads/lead.stage.changed-v1.json +44 -0
  24. package/contracts/payment-flow/schema/payloads/payment.failed-v1.json +88 -0
  25. package/contracts/payment-flow/schema/payloads/payment.received-v1.json +69 -0
  26. package/contracts/refund-flow/schema/payloads/refund.completed-v1.json +75 -0
  27. package/contracts/refund-flow/schema/payloads/refund.initiated-v1.json +69 -0
  28. package/dist/config.d.ts +67 -0
  29. package/dist/config.js +81 -0
  30. package/dist/dispatcher-errors.d.ts +20 -0
  31. package/dist/dispatcher-errors.js +42 -0
  32. package/dist/dispatcher.d.ts +123 -0
  33. package/dist/dispatcher.js +171 -0
  34. package/dist/dlq.d.ts +173 -0
  35. package/dist/dlq.js +391 -0
  36. package/dist/fanout-drain.d.ts +11 -0
  37. package/dist/fanout-drain.js +31 -0
  38. package/dist/fanout.d.ts +144 -0
  39. package/dist/fanout.js +321 -0
  40. package/dist/inbox.d.ts +125 -0
  41. package/dist/inbox.js +120 -0
  42. package/dist/index.d.ts +36 -0
  43. package/dist/index.js +70 -0
  44. package/dist/internal/id.d.ts +38 -0
  45. package/dist/internal/id.js +78 -0
  46. package/dist/internal/pg-search-path.d.ts +34 -0
  47. package/dist/internal/pg-search-path.js +55 -0
  48. package/dist/internal/resolve-contract-path.d.ts +41 -0
  49. package/dist/internal/resolve-contract-path.js +65 -0
  50. package/dist/observability.d.ts +24 -0
  51. package/dist/observability.js +37 -0
  52. package/dist/postgres-consumer.d.ts +175 -0
  53. package/dist/postgres-consumer.js +561 -0
  54. package/dist/postgres-transport.d.ts +70 -0
  55. package/dist/postgres-transport.js +144 -0
  56. package/dist/producer-db.d.ts +80 -0
  57. package/dist/producer-db.js +115 -0
  58. package/dist/registry.d.ts +94 -0
  59. package/dist/registry.js +99 -0
  60. package/dist/signature.d.ts +44 -0
  61. package/dist/signature.js +79 -0
  62. package/dist/types.d.ts +107 -0
  63. package/dist/types.js +13 -0
  64. package/dist/validator.d.ts +60 -0
  65. package/dist/validator.js +171 -0
  66. package/package.json +48 -0
@@ -0,0 +1,75 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.sguild/refund-flow/schema/payloads/refund.completed-v1.json",
4
+ "title": "refund.completed payload v1",
5
+ "description": "Payload for the refund.completed event_type per contracts/refund-flow/README.md §4.2. Emitted by Revenue at the writeback transaction commit when the Square webhook confirms the refund settled, the Refund record flips to COMPLETED, the Refund Debit ledger entries post, and the Order status flips to PARTIALLY_REFUNDED or REFUNDED. Producer SHALL emit inside the same Prisma transaction as the writeback per ADR-0009. Subscribers: Sales (Lead reactivation routing), Delivery (lock-released invariant verification per §6), platform-warehouse.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "refund_id",
10
+ "order_id",
11
+ "person_id",
12
+ "amount_cents",
13
+ "currency",
14
+ "provider_ref",
15
+ "initiated_at",
16
+ "completed_at"
17
+ ],
18
+ "properties": {
19
+ "refund_id": {
20
+ "type": "string",
21
+ "description": "The Revenue-owned Refund record. Matches the refund_id on the matching refund.initiated event. ref_<UUID v7>.",
22
+ "pattern": "^ref_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
23
+ },
24
+ "order_id": {
25
+ "type": "string",
26
+ "description": "The Order this refund is against. ord_<UUID v7>.",
27
+ "pattern": "^ord_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
28
+ },
29
+ "person_id": {
30
+ "type": "string",
31
+ "description": "The Person being refunded. per_<UUID v7>.",
32
+ "pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
33
+ },
34
+ "amount_cents": {
35
+ "type": "integer",
36
+ "description": "Refund amount in the smallest currency unit. Matches the amount_cents on the matching refund.initiated event.",
37
+ "minimum": 1
38
+ },
39
+ "currency": {
40
+ "type": "string",
41
+ "description": "ISO-4217 currency code. Matches the matching refund.initiated event.",
42
+ "pattern": "^[A-Z]{3}$"
43
+ },
44
+ "provider_ref": {
45
+ "type": "string",
46
+ "description": "Provider-side identifier for the refund. Matches the matching refund.initiated event.",
47
+ "minLength": 1
48
+ },
49
+ "initiated_at": {
50
+ "type": "string",
51
+ "format": "date-time",
52
+ "description": "The original initiation timestamp from the matching refund.initiated event. Carried on this event for correlation; consumers MAY use (refund_id, initiated_at) as the dedup key for cross-event correlation."
53
+ },
54
+ "completed_at": {
55
+ "type": "string",
56
+ "format": "date-time",
57
+ "description": "Wall-clock UTC timestamp at the writeback transaction commit. Producer's clock."
58
+ },
59
+ "cancellation_reason": {
60
+ "type": "string",
61
+ "description": "Optional, same shape as on refund.initiated. Mirrors the upstream credit.released v2 reason_code for lock-tied refunds; null for non-lock refunds. Consumers SHALL treat unknown values as safe-to-ignore per §8.",
62
+ "enum": [
63
+ "site_closure",
64
+ "coach_unavailable_reschedule_failed",
65
+ "force_majeure",
66
+ "weather",
67
+ "administrative_void",
68
+ "customer_requested_in_window",
69
+ "customer_requested_exception",
70
+ "policy_exception",
71
+ "bad_debt_writeoff"
72
+ ]
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,69 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://contracts.sguild/refund-flow/schema/payloads/refund.initiated-v1.json",
4
+ "title": "refund.initiated payload v1",
5
+ "description": "Payload for the refund.initiated event_type per contracts/refund-flow/README.md §4.1. Emitted by Revenue at the writeback transaction commit when the Refund record is minted at PENDING and the Square refund call has been dispatched. Producer SHALL emit inside the same Prisma transaction as the Refund record insert per ADR-0009. Subscribers: Sales (refund-against-Lead reactivation routing), platform-warehouse. Delivery does not subscribe at v1; the lock-released invariant verification fires on refund.completed.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "refund_id",
10
+ "order_id",
11
+ "person_id",
12
+ "amount_cents",
13
+ "currency",
14
+ "provider_ref",
15
+ "initiated_at"
16
+ ],
17
+ "properties": {
18
+ "refund_id": {
19
+ "type": "string",
20
+ "description": "The Revenue-owned Refund record. ref_<UUID v7>.",
21
+ "pattern": "^ref_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
22
+ },
23
+ "order_id": {
24
+ "type": "string",
25
+ "description": "The Order this refund is against. ord_<UUID v7>.",
26
+ "pattern": "^ord_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
27
+ },
28
+ "person_id": {
29
+ "type": "string",
30
+ "description": "The Person being refunded. per_<UUID v7>.",
31
+ "pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
32
+ },
33
+ "amount_cents": {
34
+ "type": "integer",
35
+ "description": "Refund amount in the smallest currency unit. Positive integer; the sign on the Refund Debit ledger entry is internal to Revenue.",
36
+ "minimum": 1
37
+ },
38
+ "currency": {
39
+ "type": "string",
40
+ "description": "ISO-4217 currency code. Matches the originating Order's currency.",
41
+ "pattern": "^[A-Z]{3}$"
42
+ },
43
+ "provider_ref": {
44
+ "type": "string",
45
+ "description": "Provider-side identifier for the refund per the funding-state external-reference rule. Square refund id (sqref_<id>) for refunds reaching the provider; external-actions row id (ext_ prefix) for credit-balance-only refunds.",
46
+ "minLength": 1
47
+ },
48
+ "initiated_at": {
49
+ "type": "string",
50
+ "format": "date-time",
51
+ "description": "Wall-clock UTC timestamp at the writeback transaction commit. Producer's clock. Matches the initiated_at on the corresponding refund.completed event for correlation."
52
+ },
53
+ "cancellation_reason": {
54
+ "type": "string",
55
+ "description": "Optional. When the refund traces back to a credit-reservation-lock cancellation, mirrors the reason_code from the upstream credit.released v2 event per credit-reservation-lock §6.1. Null for non-lock refunds. Consumers SHALL treat unknown values as safe-to-ignore per §8.",
56
+ "enum": [
57
+ "site_closure",
58
+ "coach_unavailable_reschedule_failed",
59
+ "force_majeure",
60
+ "weather",
61
+ "administrative_void",
62
+ "customer_requested_in_window",
63
+ "customer_requested_exception",
64
+ "policy_exception",
65
+ "bad_debt_writeoff"
66
+ ]
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Dispatcher runtime configuration.
3
+ *
4
+ * Two values every emit needs that are runtime-context rather than per-call
5
+ * input: `producer` (which domain emitted the event) and `tenant_id` (the
6
+ * tenancy scope per ADR-0001). Both live as runtime config rather than
7
+ * arguments to `dispatcher.publish` because they are properties of the
8
+ * deploy, not of the individual call site; threading them through every
9
+ * publish call would be both noisy and a source of drift if a producer
10
+ * accidentally passed the wrong value.
11
+ *
12
+ * Phase 2 deployment shape: single-tenant Sguild, one Node process per
13
+ * domain repo. Each repo's bootstrap (or the dev-server entry point) calls
14
+ * `configureDispatcher({ producer, tenantId })` once at startup; the
15
+ * env-driven fallback below covers the dev-loop and CI cases.
16
+ *
17
+ * Multi-tenant deployment shape (forward-compatible per envelope contract
18
+ * §11.2): the runtime config moves into an async-local-storage context
19
+ * keyed by request, so a single Node process can emit events for multiple
20
+ * tenants. Out of scope for Phase 2 since Sguild is single-tenant today;
21
+ * the API stays the same when the multi-tenant version lands (callers
22
+ * still call `getDispatcherConfig()`, the implementation reads from ALS
23
+ * instead of a module-level variable).
24
+ *
25
+ * Prisma client injection: the publish path inserts the event row through
26
+ * a Prisma client. The SDK does not import any domain's generated client
27
+ * directly (it ships as `@sguild/dispatcher` with `@prisma/client` as a
28
+ * peer dependency), so the consuming domain injects its own client here at
29
+ * startup via `configureDispatcher({ ..., prismaClient })`. Per-call
30
+ * publishes that pass an interactive-transaction `tx` do not need the
31
+ * injected client; the injected client is the default for publishes with
32
+ * no caller-supplied transaction.
33
+ */
34
+ import type { PrismaClient } from "@prisma/client";
35
+ import type { LiveProducerDomain } from "./types";
36
+ export type DispatcherConfig = {
37
+ /** Which domain emitted the event. Goes onto every envelope's `producer` field. */
38
+ producer: LiveProducerDomain;
39
+ /** Tenancy scope per ADR-0001. Goes onto every envelope's `tenant_id` field. */
40
+ tenantId: string;
41
+ /**
42
+ * Prisma client used for the publish path when a per-call `tx` is not
43
+ * supplied. The consuming domain injects its own generated client (each
44
+ * domain owns its `DispatcherEvent` model + migration per ADR-0009's
45
+ * per-domain table family). Optional: a process that only ever publishes
46
+ * inside caller-supplied transactions, or only consumes, does not need it.
47
+ */
48
+ prismaClient?: PrismaClient;
49
+ };
50
+ /**
51
+ * Set the dispatcher's runtime config. Call once at process startup. A
52
+ * second call overwrites the first; the production deploy shape calls
53
+ * this once and never again, but tests may override.
54
+ */
55
+ export declare function configureDispatcher(config: DispatcherConfig): void;
56
+ /**
57
+ * Get the dispatcher's runtime config. Returns the value supplied to
58
+ * `configureDispatcher` if present; otherwise falls back to environment
59
+ * variables (`DISPATCHER_PRODUCER` and `DISPATCHER_TENANT_ID`). Throws
60
+ * if neither source is set, because emitting without a producer or
61
+ * tenant_id violates envelope contract §4.1.
62
+ */
63
+ export declare function getDispatcherConfig(): DispatcherConfig;
64
+ /**
65
+ * Reset the runtime config. Test-only; production code does not call this.
66
+ */
67
+ export declare function __resetDispatcherConfigForTests(): void;
package/dist/config.js ADDED
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * Dispatcher runtime configuration.
4
+ *
5
+ * Two values every emit needs that are runtime-context rather than per-call
6
+ * input: `producer` (which domain emitted the event) and `tenant_id` (the
7
+ * tenancy scope per ADR-0001). Both live as runtime config rather than
8
+ * arguments to `dispatcher.publish` because they are properties of the
9
+ * deploy, not of the individual call site; threading them through every
10
+ * publish call would be both noisy and a source of drift if a producer
11
+ * accidentally passed the wrong value.
12
+ *
13
+ * Phase 2 deployment shape: single-tenant Sguild, one Node process per
14
+ * domain repo. Each repo's bootstrap (or the dev-server entry point) calls
15
+ * `configureDispatcher({ producer, tenantId })` once at startup; the
16
+ * env-driven fallback below covers the dev-loop and CI cases.
17
+ *
18
+ * Multi-tenant deployment shape (forward-compatible per envelope contract
19
+ * §11.2): the runtime config moves into an async-local-storage context
20
+ * keyed by request, so a single Node process can emit events for multiple
21
+ * tenants. Out of scope for Phase 2 since Sguild is single-tenant today;
22
+ * the API stays the same when the multi-tenant version lands (callers
23
+ * still call `getDispatcherConfig()`, the implementation reads from ALS
24
+ * instead of a module-level variable).
25
+ *
26
+ * Prisma client injection: the publish path inserts the event row through
27
+ * a Prisma client. The SDK does not import any domain's generated client
28
+ * directly (it ships as `@sguild/dispatcher` with `@prisma/client` as a
29
+ * peer dependency), so the consuming domain injects its own client here at
30
+ * startup via `configureDispatcher({ ..., prismaClient })`. Per-call
31
+ * publishes that pass an interactive-transaction `tx` do not need the
32
+ * injected client; the injected client is the default for publishes with
33
+ * no caller-supplied transaction.
34
+ */
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.configureDispatcher = configureDispatcher;
37
+ exports.getDispatcherConfig = getDispatcherConfig;
38
+ exports.__resetDispatcherConfigForTests = __resetDispatcherConfigForTests;
39
+ let runtimeConfig = null;
40
+ /**
41
+ * Set the dispatcher's runtime config. Call once at process startup. A
42
+ * second call overwrites the first; the production deploy shape calls
43
+ * this once and never again, but tests may override.
44
+ */
45
+ function configureDispatcher(config) {
46
+ runtimeConfig = config;
47
+ }
48
+ /**
49
+ * Get the dispatcher's runtime config. Returns the value supplied to
50
+ * `configureDispatcher` if present; otherwise falls back to environment
51
+ * variables (`DISPATCHER_PRODUCER` and `DISPATCHER_TENANT_ID`). Throws
52
+ * if neither source is set, because emitting without a producer or
53
+ * tenant_id violates envelope contract §4.1.
54
+ */
55
+ function getDispatcherConfig() {
56
+ if (runtimeConfig)
57
+ return runtimeConfig;
58
+ const producerEnv = process.env.DISPATCHER_PRODUCER;
59
+ const tenantEnv = process.env.DISPATCHER_TENANT_ID;
60
+ if (!producerEnv || !tenantEnv) {
61
+ throw new Error("Dispatcher runtime config is not set. Either call configureDispatcher({ producer, tenantId }) at process startup or set DISPATCHER_PRODUCER and DISPATCHER_TENANT_ID environment variables. Both are required by envelope contract §4.1.");
62
+ }
63
+ if (!isLiveProducerDomain(producerEnv)) {
64
+ throw new Error(`DISPATCHER_PRODUCER value '${producerEnv}' is not a recognized live producer domain. Expected one of: platform, growth, sales, delivery, revenue, coaching.`);
65
+ }
66
+ return { producer: producerEnv, tenantId: tenantEnv };
67
+ }
68
+ /**
69
+ * Reset the runtime config. Test-only; production code does not call this.
70
+ */
71
+ function __resetDispatcherConfigForTests() {
72
+ runtimeConfig = null;
73
+ }
74
+ function isLiveProducerDomain(value) {
75
+ return (value === "platform" ||
76
+ value === "growth" ||
77
+ value === "sales" ||
78
+ value === "delivery" ||
79
+ value === "revenue" ||
80
+ value === "coaching");
81
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Dispatcher error classes shared between the public dispatcher surface
3
+ * and the transport implementations. Extracted into their own module so
4
+ * postgres-transport.ts can throw `UnregisteredEventTypeError` and
5
+ * `UnregisteredSchemaVersionError` without circular-importing the
6
+ * dispatcher singleton.
7
+ *
8
+ * `DispatcherNotImplementedError` is no longer thrown by `publish` (Slice
9
+ * 2 wires the Postgres-queue transport in); it is still thrown by
10
+ * `subscribe` until Slice 3 lands the consumer-side polling worker.
11
+ */
12
+ export declare class DispatcherNotImplementedError extends Error {
13
+ constructor(operation: "publish" | "subscribe");
14
+ }
15
+ export declare class UnregisteredEventTypeError extends Error {
16
+ constructor(event_type: string);
17
+ }
18
+ export declare class UnregisteredSchemaVersionError extends Error {
19
+ constructor(event_type: string, schema_version: number | null);
20
+ }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ /**
3
+ * Dispatcher error classes shared between the public dispatcher surface
4
+ * and the transport implementations. Extracted into their own module so
5
+ * postgres-transport.ts can throw `UnregisteredEventTypeError` and
6
+ * `UnregisteredSchemaVersionError` without circular-importing the
7
+ * dispatcher singleton.
8
+ *
9
+ * `DispatcherNotImplementedError` is no longer thrown by `publish` (Slice
10
+ * 2 wires the Postgres-queue transport in); it is still thrown by
11
+ * `subscribe` until Slice 3 lands the consumer-side polling worker.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.UnregisteredSchemaVersionError = exports.UnregisteredEventTypeError = exports.DispatcherNotImplementedError = void 0;
15
+ class DispatcherNotImplementedError extends Error {
16
+ constructor(operation) {
17
+ super(`Dispatcher.${operation} is not implemented in the current SDK slice. ` +
18
+ `Tracking is in memos/2026/2026-05-09-platform-q2-airtable-sunset-directive ` +
19
+ `(Phase 2 ship 2026-06-22) and the Platform-owed ledger at memos/2026/2026-05-01-platform-owed-ledger.`);
20
+ this.name = "DispatcherNotImplementedError";
21
+ }
22
+ }
23
+ exports.DispatcherNotImplementedError = DispatcherNotImplementedError;
24
+ class UnregisteredEventTypeError extends Error {
25
+ constructor(event_type) {
26
+ super(`Event type '${event_type}' is not registered in the event-type registry ` +
27
+ `(coordination/contracts/event-types-registry.json). Producers SHALL register ` +
28
+ `every new event_type before first use per §10.4 of the event-envelope contract. ` +
29
+ `Add the event_type to the registry with an initial payload schema and re-emit.`);
30
+ this.name = "UnregisteredEventTypeError";
31
+ }
32
+ }
33
+ exports.UnregisteredEventTypeError = UnregisteredEventTypeError;
34
+ class UnregisteredSchemaVersionError extends Error {
35
+ constructor(event_type, schema_version) {
36
+ super(schema_version === null
37
+ ? `Event type '${event_type}' has no active schema_version. Set one in the registry or supply a version explicitly on EventEmit.`
38
+ : `Schema version ${schema_version} is not registered for event_type '${event_type}'. Register it in coordination/contracts/event-types-registry.json before emitting.`);
39
+ this.name = "UnregisteredSchemaVersionError";
40
+ }
41
+ }
42
+ exports.UnregisteredSchemaVersionError = UnregisteredSchemaVersionError;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Dispatcher SDK public API.
3
+ *
4
+ * This file is the consumer-facing surface every domain imports from. The
5
+ * shape is locked in by the build plan memo (memos/2026/2026-05-01-platform-
6
+ * dispatcher-sdk-build-plan §"Consumer-side subscription API"): imperative
7
+ * `dispatcher.subscribe(eventType, handler)` and `dispatcher.publish(event)`,
8
+ * with the handler signature returning a typed validated envelope.
9
+ *
10
+ * Phase 2 status: `publish` is wired to the Postgres-queue transport per
11
+ * ADR-0009; producers can emit events today against the live
12
+ * dispatcher_event table. `subscribe` still throws
13
+ * `DispatcherNotImplementedError` until Slice 3 lands the consumer-side
14
+ * polling worker.
15
+ *
16
+ * Why ship the API surface before the consumer-side implementation: types
17
+ * and method signatures are the contract. Domains scaffolding their
18
+ * subscriber code (Coaching's availability-projection module, Delivery's
19
+ * coach-day rewrite) compile against this surface today; when the polling
20
+ * worker lands, the same code starts working without consumer-side changes.
21
+ */
22
+ import type { EventEmit, PublishResult, SubscribeHandler } from "./types";
23
+ import { DispatcherNotImplementedError, UnregisteredEventTypeError, UnregisteredSchemaVersionError } from "./dispatcher-errors";
24
+ import { type ConsumerLoopOptions } from "./postgres-consumer";
25
+ import { type PublishOptions } from "./postgres-transport";
26
+ export { DispatcherNotImplementedError, UnregisteredEventTypeError, UnregisteredSchemaVersionError, };
27
+ /**
28
+ * The Dispatcher singleton. Producers and consumers import this directly:
29
+ *
30
+ * Producer side:
31
+ *
32
+ * ```ts
33
+ * import { dispatcher } from "@sguild/dispatcher";
34
+ *
35
+ * await prisma.$transaction(async (tx) => {
36
+ * await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
37
+ * await dispatcher.publish({
38
+ * event_type: "credit.locked",
39
+ * payload: { credit_reservation_id: id, ... },
40
+ * subject: personId,
41
+ * actor: "system:revenue",
42
+ * }, { tx });
43
+ * });
44
+ * ```
45
+ *
46
+ * Consumer side (in `<repo>/scripts/<consumer>-subscriber.ts`):
47
+ *
48
+ * ```ts
49
+ * import { configureDispatcher, dispatcher } from "@sguild/dispatcher";
50
+ *
51
+ * configureDispatcher({ producer: "coaching", tenantId: "org_sguild" });
52
+ *
53
+ * dispatcher.subscribe<CreditLockedPayload>("credit.locked", async (event) => {
54
+ * // handle event
55
+ * });
56
+ *
57
+ * await dispatcher.start({ consumer: "coaching-availability-subscriber" });
58
+ * // process.on("SIGTERM", () => dispatcher.stop());
59
+ * ```
60
+ *
61
+ * Phase 2 status: `publish` is wired to the Postgres-queue transport;
62
+ * `subscribe` registers the handler in the singleton; `start` begins the
63
+ * polling worker. LISTEN/NOTIFY wake-up lands in Slice 3b; today the
64
+ * worker is poll-only at the configured cadence.
65
+ */
66
+ declare class Dispatcher {
67
+ private readonly registeredHandlers;
68
+ private consumerLoop;
69
+ /**
70
+ * Publish an event. Auto-populates SDK-controlled envelope fields,
71
+ * validates envelope and payload against the registry's schemas, and
72
+ * inserts into the dispatcher_event table using the optional Prisma
73
+ * transaction client (the producer-transactional-guarantee primitive
74
+ * ADR-0009 chose). Returns the canonical event_id for retry
75
+ * correlation per envelope contract §10.6.
76
+ */
77
+ publish<TPayload>(emit: EventEmit<TPayload>, options?: PublishOptions): Promise<PublishResult>;
78
+ /**
79
+ * Register a handler for an event_type. The handler is invoked
80
+ * at-most-once per (consumer, event_id) per §9.2 of the envelope
81
+ * contract; redelivery is handled inside the SDK and does not
82
+ * surface to the handler unless the handler throws.
83
+ *
84
+ * Multiple subscribe calls register multiple handlers (one per
85
+ * event_type per consumer). Calling subscribe after start() is a
86
+ * usage error: the polling loop reads the handler list at start
87
+ * time. Tests reset via __resetForTests().
88
+ *
89
+ * @throws UnregisteredEventTypeError if the event_type is not in the
90
+ * registry. Catches subscribers wired against typos or unregistered
91
+ * event_types at registration time rather than at runtime.
92
+ */
93
+ subscribe<TPayload>(event_type: string, handler: SubscribeHandler<TPayload>): void;
94
+ /**
95
+ * Start the consumer-side polling worker. Drives the registered
96
+ * handlers against the dispatcher_event table; each handler receives
97
+ * its event_type's events in seq order, with dedup, retry, and
98
+ * dead-letter handling per ADR-0009 §"Decision".
99
+ *
100
+ * Resolves when stop() is called and the in-flight batch (if any)
101
+ * finishes. Otherwise runs forever; consumers typically wire
102
+ * `process.on("SIGTERM", () => dispatcher.stop())` so a deploy
103
+ * rollover drains cleanly.
104
+ */
105
+ start(options: ConsumerLoopOptions): Promise<void>;
106
+ /**
107
+ * Graceful shutdown. The polling loop finishes the current batch
108
+ * (so an in-flight handler invocation completes rather than getting
109
+ * cut off) and then exits.
110
+ */
111
+ stop(): Promise<void>;
112
+ /**
113
+ * Reset registered handlers and the consumer loop. Test-only;
114
+ * production code does not call this. Allows test harnesses to
115
+ * subscribe multiple times across test fixtures without spinning up
116
+ * a fresh process.
117
+ */
118
+ __resetForTests(): void;
119
+ }
120
+ /**
121
+ * Singleton dispatcher instance. Domain code imports this directly.
122
+ */
123
+ export declare const dispatcher: Dispatcher;
@@ -0,0 +1,171 @@
1
+ "use strict";
2
+ /**
3
+ * Dispatcher SDK public API.
4
+ *
5
+ * This file is the consumer-facing surface every domain imports from. The
6
+ * shape is locked in by the build plan memo (memos/2026/2026-05-01-platform-
7
+ * dispatcher-sdk-build-plan §"Consumer-side subscription API"): imperative
8
+ * `dispatcher.subscribe(eventType, handler)` and `dispatcher.publish(event)`,
9
+ * with the handler signature returning a typed validated envelope.
10
+ *
11
+ * Phase 2 status: `publish` is wired to the Postgres-queue transport per
12
+ * ADR-0009; producers can emit events today against the live
13
+ * dispatcher_event table. `subscribe` still throws
14
+ * `DispatcherNotImplementedError` until Slice 3 lands the consumer-side
15
+ * polling worker.
16
+ *
17
+ * Why ship the API surface before the consumer-side implementation: types
18
+ * and method signatures are the contract. Domains scaffolding their
19
+ * subscriber code (Coaching's availability-projection module, Delivery's
20
+ * coach-day rewrite) compile against this surface today; when the polling
21
+ * worker lands, the same code starts working without consumer-side changes.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.dispatcher = exports.UnregisteredSchemaVersionError = exports.UnregisteredEventTypeError = exports.DispatcherNotImplementedError = void 0;
25
+ const dispatcher_errors_1 = require("./dispatcher-errors");
26
+ Object.defineProperty(exports, "DispatcherNotImplementedError", { enumerable: true, get: function () { return dispatcher_errors_1.DispatcherNotImplementedError; } });
27
+ Object.defineProperty(exports, "UnregisteredEventTypeError", { enumerable: true, get: function () { return dispatcher_errors_1.UnregisteredEventTypeError; } });
28
+ Object.defineProperty(exports, "UnregisteredSchemaVersionError", { enumerable: true, get: function () { return dispatcher_errors_1.UnregisteredSchemaVersionError; } });
29
+ const postgres_consumer_1 = require("./postgres-consumer");
30
+ const registry_1 = require("./registry");
31
+ const postgres_transport_1 = require("./postgres-transport");
32
+ /**
33
+ * The Dispatcher singleton. Producers and consumers import this directly:
34
+ *
35
+ * Producer side:
36
+ *
37
+ * ```ts
38
+ * import { dispatcher } from "@sguild/dispatcher";
39
+ *
40
+ * await prisma.$transaction(async (tx) => {
41
+ * await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
42
+ * await dispatcher.publish({
43
+ * event_type: "credit.locked",
44
+ * payload: { credit_reservation_id: id, ... },
45
+ * subject: personId,
46
+ * actor: "system:revenue",
47
+ * }, { tx });
48
+ * });
49
+ * ```
50
+ *
51
+ * Consumer side (in `<repo>/scripts/<consumer>-subscriber.ts`):
52
+ *
53
+ * ```ts
54
+ * import { configureDispatcher, dispatcher } from "@sguild/dispatcher";
55
+ *
56
+ * configureDispatcher({ producer: "coaching", tenantId: "org_sguild" });
57
+ *
58
+ * dispatcher.subscribe<CreditLockedPayload>("credit.locked", async (event) => {
59
+ * // handle event
60
+ * });
61
+ *
62
+ * await dispatcher.start({ consumer: "coaching-availability-subscriber" });
63
+ * // process.on("SIGTERM", () => dispatcher.stop());
64
+ * ```
65
+ *
66
+ * Phase 2 status: `publish` is wired to the Postgres-queue transport;
67
+ * `subscribe` registers the handler in the singleton; `start` begins the
68
+ * polling worker. LISTEN/NOTIFY wake-up lands in Slice 3b; today the
69
+ * worker is poll-only at the configured cadence.
70
+ */
71
+ class Dispatcher {
72
+ // Subscribe-side state lives on the singleton because the worker
73
+ // polls handlers registered through this instance. Tests reset via
74
+ // __resetForTests().
75
+ registeredHandlers = [];
76
+ consumerLoop = null;
77
+ /**
78
+ * Publish an event. Auto-populates SDK-controlled envelope fields,
79
+ * validates envelope and payload against the registry's schemas, and
80
+ * inserts into the dispatcher_event table using the optional Prisma
81
+ * transaction client (the producer-transactional-guarantee primitive
82
+ * ADR-0009 chose). Returns the canonical event_id for retry
83
+ * correlation per envelope contract §10.6.
84
+ */
85
+ async publish(emit, options = {}) {
86
+ // Phase 2 Slice 2: publish is wired to the Postgres-queue transport.
87
+ // Registry lookup, schema_version resolution, envelope construction,
88
+ // envelope and payload validation, and the same-transaction insert
89
+ // all live in the transport layer per ADR-0009 §"Decision". The
90
+ // public surface here is intentionally thin so the runtime can swap
91
+ // transports without changing the call shape (the trigger-to-revisit
92
+ // framework in ADR-0009 names NATS JetStream as the named successor
93
+ // when one of three trigger conditions fires; that swap rebinds
94
+ // publishToPostgres → publishToNats here without touching callers).
95
+ return (0, postgres_transport_1.publishToPostgres)(emit, options);
96
+ }
97
+ /**
98
+ * Register a handler for an event_type. The handler is invoked
99
+ * at-most-once per (consumer, event_id) per §9.2 of the envelope
100
+ * contract; redelivery is handled inside the SDK and does not
101
+ * surface to the handler unless the handler throws.
102
+ *
103
+ * Multiple subscribe calls register multiple handlers (one per
104
+ * event_type per consumer). Calling subscribe after start() is a
105
+ * usage error: the polling loop reads the handler list at start
106
+ * time. Tests reset via __resetForTests().
107
+ *
108
+ * @throws UnregisteredEventTypeError if the event_type is not in the
109
+ * registry. Catches subscribers wired against typos or unregistered
110
+ * event_types at registration time rather than at runtime.
111
+ */
112
+ subscribe(event_type, handler) {
113
+ const registration = (0, registry_1.getEventTypeRegistration)(event_type);
114
+ if (!registration) {
115
+ throw new dispatcher_errors_1.UnregisteredEventTypeError(event_type);
116
+ }
117
+ if (this.consumerLoop) {
118
+ throw new Error(`dispatcher.subscribe('${event_type}'): cannot register a handler after start(); register all handlers first, then call start({ consumer })`);
119
+ }
120
+ this.registeredHandlers.push({
121
+ eventType: event_type,
122
+ handler: handler,
123
+ });
124
+ }
125
+ /**
126
+ * Start the consumer-side polling worker. Drives the registered
127
+ * handlers against the dispatcher_event table; each handler receives
128
+ * its event_type's events in seq order, with dedup, retry, and
129
+ * dead-letter handling per ADR-0009 §"Decision".
130
+ *
131
+ * Resolves when stop() is called and the in-flight batch (if any)
132
+ * finishes. Otherwise runs forever; consumers typically wire
133
+ * `process.on("SIGTERM", () => dispatcher.stop())` so a deploy
134
+ * rollover drains cleanly.
135
+ */
136
+ async start(options) {
137
+ if (this.consumerLoop) {
138
+ throw new Error(`dispatcher.start({ consumer: '${options.consumer}' }): already running; call stop() before starting a new loop`);
139
+ }
140
+ if (this.registeredHandlers.length === 0) {
141
+ throw new Error("dispatcher.start: no handlers registered; call subscribe() at least once before start()");
142
+ }
143
+ this.consumerLoop = new postgres_consumer_1.ConsumerLoop(this.registeredHandlers, options);
144
+ await this.consumerLoop.start();
145
+ }
146
+ /**
147
+ * Graceful shutdown. The polling loop finishes the current batch
148
+ * (so an in-flight handler invocation completes rather than getting
149
+ * cut off) and then exits.
150
+ */
151
+ async stop() {
152
+ if (!this.consumerLoop)
153
+ return;
154
+ await this.consumerLoop.stop();
155
+ this.consumerLoop = null;
156
+ }
157
+ /**
158
+ * Reset registered handlers and the consumer loop. Test-only;
159
+ * production code does not call this. Allows test harnesses to
160
+ * subscribe multiple times across test fixtures without spinning up
161
+ * a fresh process.
162
+ */
163
+ __resetForTests() {
164
+ this.registeredHandlers.length = 0;
165
+ this.consumerLoop = null;
166
+ }
167
+ }
168
+ /**
169
+ * Singleton dispatcher instance. Domain code imports this directly.
170
+ */
171
+ exports.dispatcher = new Dispatcher();