@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,175 @@
1
+ /**
2
+ * Postgres-queue transport: consumer-side polling worker.
3
+ *
4
+ * Implements the consume path of the bus dispatcher per ADR-0009
5
+ * §"Decision". Long-lived process (typically run from
6
+ * `<repo>/scripts/<consumer>-subscriber.ts` per the per-domain module
7
+ * pattern) that polls `dispatcher_event` past a per-(consumer,
8
+ * event_type) cursor, dedups against `dispatcher_dedup`, invokes the
9
+ * registered handler, retries on exception with exponential backoff and
10
+ * jitter, dead-letters on exhaustion, and advances the cursor atomically
11
+ * with the dedup write on success (or the dead-letter write on
12
+ * exhaustion).
13
+ *
14
+ * Phase 2 Slice 3 (this file): poll-only consumer. The LISTEN/NOTIFY
15
+ * wake-up primitive ADR-0009 §"Decision" calls for is the Slice 3b
16
+ * deliverable; Slice 3 ships poll-only because the polling loop is the
17
+ * durable fallback regardless of whether LISTEN/NOTIFY is wired, and
18
+ * shipping poll-only first gives consumers a working subscriber surface
19
+ * to scaffold against. Consumers cut over to the same call shape when
20
+ * LISTEN/NOTIFY lands; only the latency profile changes (sub-second on
21
+ * the happy path, polling cadence as the fallback under either model).
22
+ *
23
+ * Per-consumer scope: each consumer instance is identified by a
24
+ * stable string (e.g., "coaching-availability-subscriber") that goes
25
+ * onto the cursor and dedup rows. A given consumer string MUST be
26
+ * unique across deployments; running two processes against the same
27
+ * consumer string causes them to compete for cursor advancement and
28
+ * the dedup window collapses to per-consumer-id rather than
29
+ * per-process. Per the build plan §3, the typical shape is one
30
+ * consumer string per long-lived worker process per domain.
31
+ *
32
+ * Per-event-type fan-out: a single ConsumerLoop instance processes
33
+ * all subscribed event_types serially within a poll cycle (one
34
+ * batch per event_type, in registration order). For workloads where
35
+ * one event_type has substantially higher volume than others and the
36
+ * serial drain becomes a latency bottleneck, run multiple
37
+ * ConsumerLoop instances against the same database with different
38
+ * consumer strings — but typical Sguild volume per consumer fits
39
+ * comfortably inside a single loop's drain cadence.
40
+ */
41
+ import { Pool } from "pg";
42
+ import type { SubscribeHandler } from "./types";
43
+ export type ConsumerLoopOptions = {
44
+ /**
45
+ * Stable identifier for this consumer. Goes onto cursor, dedup, and
46
+ * dead-letter rows. MUST be unique across deployments; the convention
47
+ * is one consumer string per long-lived worker process per domain
48
+ * (e.g., "coaching-availability-subscriber",
49
+ * "delivery-customer-tracking-subscriber").
50
+ */
51
+ consumer: string;
52
+ /** Default 50. Larger values amortize cursor I/O over more rows; smaller values reduce per-cycle latency at the tail. */
53
+ batchSize?: number;
54
+ /** Default 5000 ms. Slice 3b's LISTEN/NOTIFY wake-up will make this the fallback cadence rather than the typical wake-up time. */
55
+ pollIntervalMs?: number;
56
+ /** Default [1000, 5000, 15000] ms. Each value is the base; actual sleep adds 0-30 percent jitter. */
57
+ retryDelaysMs?: number[];
58
+ /**
59
+ * pg.Pool this loop reads cursor/event/dedup/dead-letter rows from
60
+ * via raw SQL. Raw SQL (unqualified table names) lets Postgres
61
+ * resolve the schema via the connection's search_path, which
62
+ * `pgConfigWithSearchPath` derives from the producer DSN's
63
+ * `?schema=<domain>` query param. This
64
+ * keeps the loop schema-agnostic so the same code works against
65
+ * Platform's `public` schema, Growth's `growth` schema, Sales' `sales`
66
+ * schema, etc., without Prisma's hard-coded `public.` qualification
67
+ * getting in the way.
68
+ *
69
+ * Defaults to a pool constructed against `process.env.DATABASE_URL` so
70
+ * the in-process Platform dispatcher path keeps working unchanged. The
71
+ * multi-producer fanout overrides with a producer-specific pool from
72
+ * `producer-db.ts`.
73
+ */
74
+ pool?: Pool;
75
+ /**
76
+ * DSN used for the LISTEN/NOTIFY wake-up connection. The pg.Client
77
+ * created here MUST connect to the same database as `pool` above so
78
+ * the NOTIFY payload corresponds to the dispatcher_event rows the
79
+ * polling path reads. Defaults to `process.env.DATABASE_URL`; the
80
+ * multi-producer fanout overrides with the producer's DSN.
81
+ */
82
+ databaseUrl?: string;
83
+ /**
84
+ * Schema name parsed from the DSN's `?schema=<name>` query param
85
+ * (null/undefined means use the default search_path, typically `public`).
86
+ * When set, ConsumerLoop schema-qualifies every SQL query
87
+ * (`"<schema>".dispatcher_cursor` instead of `dispatcher_cursor`).
88
+ *
89
+ * Required for Supabase pooler DSNs because the pgbouncer transaction-mode
90
+ * pooler silently drops the libpq `options=-c search_path=...` startup
91
+ * parameter — meaning search_path is effectively whatever the connection's
92
+ * role default is, not what the DSN declared. Schema-qualified table names
93
+ * bypass search_path entirely.
94
+ */
95
+ schema?: string | null;
96
+ };
97
+ export type RegisteredHandler = {
98
+ eventType: string;
99
+ handler: SubscribeHandler<unknown>;
100
+ };
101
+ export declare class ConsumerLoop {
102
+ private readonly handlers;
103
+ private running;
104
+ private inflight;
105
+ private readonly options;
106
+ private readonly pool;
107
+ private readonly ownsPool;
108
+ private readonly databaseUrl;
109
+ /**
110
+ * Quoted table-name prefix. Empty string when no schema is configured;
111
+ * `"<schema>".` (with trailing dot) when a schema is set. Prepended to
112
+ * every dispatcher_X table reference in the SQL queries so they work
113
+ * even when Supabase's pooler drops the search_path option.
114
+ */
115
+ private readonly tablePrefix;
116
+ private readonly subscribedEventTypes;
117
+ private listenClient;
118
+ private listenReconnectScheduled;
119
+ private listenReconnectAttempt;
120
+ private sleepAbort;
121
+ constructor(handlers: ReadonlyArray<RegisteredHandler>, options: ConsumerLoopOptions);
122
+ /**
123
+ * Start the polling loop with LISTEN/NOTIFY wake-up. Resolves when
124
+ * `stop()` is called and the current in-flight batch (if any) finishes
125
+ * draining; otherwise runs forever.
126
+ *
127
+ * The LISTEN connection is best-effort: if it fails to set up or drops
128
+ * mid-flight, the polling loop still drains at the configured cadence
129
+ * and reconnect attempts run with backoff. Sub-second wake-up is the
130
+ * happy path; degraded poll-cadence wake-up is the durable fallback.
131
+ */
132
+ start(): Promise<void>;
133
+ /**
134
+ * Graceful shutdown. Sets the running flag false, wakes the polling
135
+ * loop early so it exits the current sleep without waiting for the
136
+ * full poll interval, then waits for the in-flight batch to drain
137
+ * before resolving. Closes the LISTEN connection on the way out.
138
+ */
139
+ stop(): Promise<void>;
140
+ private getCursorLastSeq;
141
+ private findEventsAfter;
142
+ private getDedupHit;
143
+ /**
144
+ * Single-transaction dedup-insert + cursor-advance. The pg.PoolClient
145
+ * is checked out for the transaction so the BEGIN/COMMIT pair is
146
+ * scoped to one connection. Errors trigger a ROLLBACK and the loop
147
+ * retries via the normal retry path.
148
+ */
149
+ private commitSuccessfulDelivery;
150
+ private commitDeadLetter;
151
+ private processBatchForEventType;
152
+ private deliverEvent;
153
+ private advanceCursor;
154
+ private sleep;
155
+ /**
156
+ * Detect whether a DSN points at a transaction-mode pooler (Supabase's
157
+ * pgbouncer at port 6543). LISTEN holds a connection open waiting for
158
+ * NOTIFY; transaction-mode pooling reuses connections across queries
159
+ * and rejects LISTEN with ENOIDENTIFIER. The fix is to skip the LISTEN
160
+ * client entirely against pooler URLs and rely on polling. Operators
161
+ * who want sub-second wake-up can either provide a session-mode URL
162
+ * (port 5432 / direct connection) via a future databaseUrl override
163
+ * or run a non-pooler DSN.
164
+ *
165
+ * Signal: `pgbouncer=true` query param OR port 6543 anywhere in the
166
+ * URL. Both are conventions Supabase generates for pooled URLs.
167
+ */
168
+ private isPoolerDsn;
169
+ private startListenConnection;
170
+ private scheduleListenReconnect;
171
+ private stopListenConnection;
172
+ private onNotification;
173
+ private wakeUp;
174
+ private sleepOrWakeUp;
175
+ }