@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.
- package/README.md +354 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json +59 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json +80 -0
- package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json +74 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.consumed-v1.json +33 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json +41 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.funded-v1.json +31 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.locked-v1.json +42 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.purchased-v1.json +39 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.released-v1.json +61 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.released-v2.json +77 -0
- package/contracts/credit-reservation-lock/schema/payloads/credit.reserved-v1.json +60 -0
- package/contracts/credit-reservation-lock/schema/payloads/customer.handoff-v1.json +35 -0
- package/contracts/event-envelope/schema/envelope-v1.json +79 -0
- package/contracts/event-types-registry.json +541 -0
- package/contracts/identity/schema/payloads/intake.amended-v1.json +124 -0
- package/contracts/identity/schema/payloads/intake.captured-v2.json +114 -0
- package/contracts/identity/schema/payloads/person.updated-v1.json +99 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.attempt.exhausted-v1.json +36 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.callback.scheduled-v1.json +39 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.created-v1.json +50 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.reached-v1.json +39 -0
- package/contracts/lead-lifecycle/schema/payloads/lead.stage.changed-v1.json +44 -0
- package/contracts/payment-flow/schema/payloads/payment.failed-v1.json +88 -0
- package/contracts/payment-flow/schema/payloads/payment.received-v1.json +69 -0
- package/contracts/refund-flow/schema/payloads/refund.completed-v1.json +75 -0
- package/contracts/refund-flow/schema/payloads/refund.initiated-v1.json +69 -0
- package/dist/config.d.ts +67 -0
- package/dist/config.js +81 -0
- package/dist/dispatcher-errors.d.ts +20 -0
- package/dist/dispatcher-errors.js +42 -0
- package/dist/dispatcher.d.ts +123 -0
- package/dist/dispatcher.js +171 -0
- package/dist/dlq.d.ts +173 -0
- package/dist/dlq.js +391 -0
- package/dist/fanout-drain.d.ts +11 -0
- package/dist/fanout-drain.js +31 -0
- package/dist/fanout.d.ts +144 -0
- package/dist/fanout.js +321 -0
- package/dist/inbox.d.ts +125 -0
- package/dist/inbox.js +120 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +70 -0
- package/dist/internal/id.d.ts +38 -0
- package/dist/internal/id.js +78 -0
- package/dist/internal/pg-search-path.d.ts +34 -0
- package/dist/internal/pg-search-path.js +55 -0
- package/dist/internal/resolve-contract-path.d.ts +41 -0
- package/dist/internal/resolve-contract-path.js +65 -0
- package/dist/observability.d.ts +24 -0
- package/dist/observability.js +37 -0
- package/dist/postgres-consumer.d.ts +175 -0
- package/dist/postgres-consumer.js +561 -0
- package/dist/postgres-transport.d.ts +70 -0
- package/dist/postgres-transport.js +144 -0
- package/dist/producer-db.d.ts +80 -0
- package/dist/producer-db.js +115 -0
- package/dist/registry.d.ts +94 -0
- package/dist/registry.js +99 -0
- package/dist/signature.d.ts +44 -0
- package/dist/signature.js +79 -0
- package/dist/types.d.ts +107 -0
- package/dist/types.js +13 -0
- package/dist/validator.d.ts +60 -0
- package/dist/validator.js +171 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# Dispatcher SDK
|
|
2
|
+
|
|
3
|
+
Cross-domain event dispatcher per the Sguild Event Envelope contract
|
|
4
|
+
(`coordination/contracts/event-envelope/README.md`, currently v1.0.2).
|
|
5
|
+
|
|
6
|
+
This module is the runtime that producers and consumers call into to emit
|
|
7
|
+
and subscribe to cross-domain events. It owns envelope construction,
|
|
8
|
+
payload validation, dedup, and transport.
|
|
9
|
+
|
|
10
|
+
It is published as the `@sguild/dispatcher` npm package so every domain
|
|
11
|
+
repo consumes one shared, versioned runtime rather than vendoring a copy.
|
|
12
|
+
The package is built and published from this directory in the platform
|
|
13
|
+
repo; see "Installing and consuming" and "Where the SDK code lives" below.
|
|
14
|
+
|
|
15
|
+
## Status: Phase 3 landed in repo
|
|
16
|
+
|
|
17
|
+
Per the Q2 Airtable sunset directive
|
|
18
|
+
(`coordination/memos/2026/2026-05-09-platform-q2-airtable-sunset-directive.md`)
|
|
19
|
+
the dispatcher SDK ship dates compressed against the original build plan.
|
|
20
|
+
Current state:
|
|
21
|
+
|
|
22
|
+
**Phase 0 (foundation): complete.** The public types, the registry
|
|
23
|
+
loader, the dispatcher API surface, and 22 unit tests landed 2026-05-02.
|
|
24
|
+
Producers and consumers compile against the surface; the runtime methods
|
|
25
|
+
still throw `DispatcherNotImplementedError` until Phase 2 Slice 2 wires
|
|
26
|
+
the transport implementation in.
|
|
27
|
+
|
|
28
|
+
**Phase 1 (in-process dispatcher): deferred indefinitely.** All five
|
|
29
|
+
non-Platform domains explicitly endorsed the Phase 0 + Phase 2 (bus-only)
|
|
30
|
+
shape per the build-plan asks closure
|
|
31
|
+
(`coordination/memos/2026/2026-05-02-platform-dispatcher-sdk-build-plan-asks-closed.md`).
|
|
32
|
+
Reopening requires a separate proposal memo on the build plan thread.
|
|
33
|
+
|
|
34
|
+
**Phase 2 (bus dispatcher): complete.** ADR-0009 accepted 2026-05-02
|
|
35
|
+
at Postgres-backed queue with LISTEN/NOTIFY wake-up
|
|
36
|
+
(`coordination/adrs/ADR-0009-dispatcher-cross-process-transport.md`).
|
|
37
|
+
Hard date for full Phase 2 ship: **2026-06-22** per the Q2 directive
|
|
38
|
+
(compressed 4 days from the original 2026-06-26 commitment).
|
|
39
|
+
|
|
40
|
+
Slice 1 (landed earlier 2026-05-09): the schema foundation.
|
|
41
|
+
|
|
42
|
+
- Four Prisma models: `DispatcherEvent`, `DispatcherCursor`,
|
|
43
|
+
`DispatcherDedup`, `DispatcherDeadLetter`. See
|
|
44
|
+
`prisma/schema.prisma` for the canonical definitions and the
|
|
45
|
+
table-by-table comment header.
|
|
46
|
+
- Raw-SQL migration at
|
|
47
|
+
`prisma/migrations/20260509190000_dispatcher_phase_2_schema/migration.sql`
|
|
48
|
+
with the four tables, six indexes (including the partial
|
|
49
|
+
`dispatcher_dead_letter_active_per_consumer` for the active-only
|
|
50
|
+
DLQ-read hot path), the `dispatcher_event_notify` trigger function,
|
|
51
|
+
and the AFTER INSERT trigger on `dispatcher_event` that fires
|
|
52
|
+
`pg_notify('dispatcher_event_inserted', NEW.event_type)` for the
|
|
53
|
+
consumer-side wake-up primitive.
|
|
54
|
+
- `ajv` and `ajv-formats` added to `package.json` for runtime payload
|
|
55
|
+
validation against the JSON Schemas in
|
|
56
|
+
`coordination/contracts/<contract>/schema/payloads/` per ADR-0009
|
|
57
|
+
action item 8.
|
|
58
|
+
|
|
59
|
+
Slice 2 (landed 2026-05-09 alongside Slice 1): the publish path.
|
|
60
|
+
|
|
61
|
+
- `lib/dispatcher/config.ts` carries the runtime config (`producer`,
|
|
62
|
+
`tenantId`, and the optional injected `prismaClient`) every emit needs.
|
|
63
|
+
Each domain repo's bootstrap calls
|
|
64
|
+
`configureDispatcher({ producer, tenantId, prismaClient })` once at
|
|
65
|
+
startup; `DISPATCHER_PRODUCER` and `DISPATCHER_TENANT_ID` env vars are
|
|
66
|
+
the fallback for `producer`/`tenantId` in dev-loop and CI. `prismaClient`
|
|
67
|
+
is the default client for publishes that do not pass a per-call `tx`;
|
|
68
|
+
the SDK keeps `@prisma/client` as a peer dependency and never imports a
|
|
69
|
+
domain's generated client directly.
|
|
70
|
+
- `lib/dispatcher/validator.ts` ships ajv-backed envelope validation
|
|
71
|
+
against `contracts/event-envelope/schema/envelope-v1.json` and
|
|
72
|
+
per-`(event_type, schema_version)` payload validation against the
|
|
73
|
+
`payload_schema` paths registered in
|
|
74
|
+
`contracts/event-types-registry.json`. Compiled validators cache
|
|
75
|
+
on first use.
|
|
76
|
+
- `lib/dispatcher/postgres-transport.ts` ships `publishToPostgres(emit,
|
|
77
|
+
options?)` with optional `tx: Prisma.TransactionClient`. The
|
|
78
|
+
function resolves the registration and schema version, builds the
|
|
79
|
+
envelope (auto-populating event_id, occurred_at, tenant_id,
|
|
80
|
+
producer, schema_version per envelope contract §10.2), validates,
|
|
81
|
+
and inserts into `dispatcher_event` using the supplied tx (or the
|
|
82
|
+
default Prisma client if no tx). The same-transaction insert is
|
|
83
|
+
the producer-transactional-guarantee primitive per ADR-0009
|
|
84
|
+
§"Producer transactional guarantee".
|
|
85
|
+
- `lib/dispatcher/dispatcher.ts` `publish` no longer throws
|
|
86
|
+
`DispatcherNotImplementedError`; it calls `publishToPostgres`
|
|
87
|
+
through to the transport. Producers can emit events today against
|
|
88
|
+
the live dispatcher_event table. Error classes extracted into
|
|
89
|
+
`lib/dispatcher/dispatcher-errors.ts` and re-exported from
|
|
90
|
+
`dispatcher.ts` so existing import sites continue to work.
|
|
91
|
+
|
|
92
|
+
Slice 3 (landed 2026-05-09 alongside Slices 1 and 2): the consumer
|
|
93
|
+
polling worker.
|
|
94
|
+
|
|
95
|
+
- `lib/dispatcher/postgres-consumer.ts` ships `ConsumerLoop`, a
|
|
96
|
+
long-lived process that polls `dispatcher_event` past the per-(consumer,
|
|
97
|
+
event_type) cursor in batches (default 50 per cycle), checks
|
|
98
|
+
`dispatcher_dedup` before invoking the handler (so a re-dispatched
|
|
99
|
+
event already delivered to this consumer skips re-invocation), retries
|
|
100
|
+
handler exceptions with exponential backoff plus 0-30 percent jitter
|
|
101
|
+
(default 3 retries on top of the initial attempt; delays
|
|
102
|
+
`[1000, 5000, 15000]` ms), and dead-letters into
|
|
103
|
+
`dispatcher_dead_letter` after retry exhaustion. Cursor advance and
|
|
104
|
+
dedup/dead-letter writes run in a single Prisma transaction per row
|
|
105
|
+
so the at-most-once promise holds across crash points.
|
|
106
|
+
- `lib/dispatcher/dispatcher.ts` `subscribe` now registers handlers in
|
|
107
|
+
the singleton's internal list (instead of throwing
|
|
108
|
+
`DispatcherNotImplementedError`). Two new methods: `start({ consumer,
|
|
109
|
+
batchSize?, pollIntervalMs?, retryDelaysMs? })` instantiates the
|
|
110
|
+
ConsumerLoop and runs it; `stop()` flips the running flag and waits
|
|
111
|
+
for the in-flight batch to drain. Subscribers typically wire
|
|
112
|
+
`process.on("SIGTERM", () => dispatcher.stop())` so a deploy rollover
|
|
113
|
+
drains cleanly.
|
|
114
|
+
- The barrel `lib/dispatcher/index.ts` re-exports `configureDispatcher`,
|
|
115
|
+
`DispatcherConfig`, `PublishOptions`, `ConsumerLoopOptions`, and the
|
|
116
|
+
three validator error classes alongside the existing surface.
|
|
117
|
+
|
|
118
|
+
Slice 3b (landed 2026-05-09 alongside Slices 1, 2, and 3): LISTEN/NOTIFY
|
|
119
|
+
wake-up.
|
|
120
|
+
|
|
121
|
+
- `lib/dispatcher/postgres-consumer.ts` `ConsumerLoop` now opens a
|
|
122
|
+
separate `pg.Client` connection (not from the Prisma pool, since LISTEN
|
|
123
|
+
ties up the connection for its duration) and runs `LISTEN
|
|
124
|
+
dispatcher_event_inserted`. On `notification` events, the consumer
|
|
125
|
+
filters on the payload (the inserted row's `event_type`); when a
|
|
126
|
+
subscribed event_type fires, the polling loop's between-cycle sleep
|
|
127
|
+
aborts via an `AbortController` and the next batch runs immediately.
|
|
128
|
+
Typical wake-up latency drops from the configured poll cadence
|
|
129
|
+
(default 5 seconds) to sub-second on the happy path.
|
|
130
|
+
- Connection lifecycle: best-effort startup (LISTEN failures don't
|
|
131
|
+
block the polling loop, just log and reconnect with backoff per
|
|
132
|
+
`LISTEN_RECONNECT_DELAYS_MS = [1000, 5000, 15000, 60000]`). On the
|
|
133
|
+
pg.Client `error` or `end` events, `scheduleListenReconnect` queues a
|
|
134
|
+
reconnect with `setTimeout` (unref'd so it doesn't block process exit).
|
|
135
|
+
Successful reconnect resets the backoff counter. `stop()` closes the
|
|
136
|
+
LISTEN connection cleanly with `UNLISTEN` followed by `client.end()`.
|
|
137
|
+
- Polling stays as the durable fallback the whole time. A dropped LISTEN
|
|
138
|
+
connection degrades wake-up latency to poll cadence but never blocks
|
|
139
|
+
delivery; missed notifications (LISTEN queue overflow, network blip,
|
|
140
|
+
reconnect window) get caught by the next poll cycle.
|
|
141
|
+
|
|
142
|
+
Slice 5 (landed 2026-05-09 alongside Slices 1, 2, 3, and 3b): per-consumer
|
|
143
|
+
DLQ read and resolve API.
|
|
144
|
+
|
|
145
|
+
- `lib/dispatcher/dlq.ts` ships three service functions:
|
|
146
|
+
`listDeadLetters(consumer, { includeResolved?, limit? })` returns the
|
|
147
|
+
active (or all) dead-letters for a consumer, sorted by `created_at`
|
|
148
|
+
descending; `getDeadLetter(deadLetterId)` returns one row by its
|
|
149
|
+
`dlq_<UUID>` id; `resolveDeadLetter(deadLetterId, { resolvedBy,
|
|
150
|
+
resolutionNote? })` marks a row resolved with `resolved_at = NOW()`
|
|
151
|
+
and the operator identifier. Resolution is one-shot; throws
|
|
152
|
+
`DeadLetterAlreadyResolvedError` on a re-resolve attempt.
|
|
153
|
+
- Three HTTP routes under `/api/dispatcher/v1/dlq/...`:
|
|
154
|
+
- `GET /api/dispatcher/v1/dlq?consumer=...&include_resolved=...&limit=...`
|
|
155
|
+
lists dead-letters for a consumer.
|
|
156
|
+
- `GET /api/dispatcher/v1/dlq/[deadLetterId]` returns one row.
|
|
157
|
+
- `POST /api/dispatcher/v1/dlq/[deadLetterId]/resolve` marks one
|
|
158
|
+
resolved; body is `{ "resolved_by": "<operator-id>",
|
|
159
|
+
"resolution_note": "<optional>" }`.
|
|
160
|
+
- Auth: `requireSession` on all three routes (parallel to the
|
|
161
|
+
identity routes' v1 surface). Tenant or role-based gating can layer
|
|
162
|
+
in when the DLQ moves to a superadmin-only surface.
|
|
163
|
+
- Index re-exports the DLQ surface alongside the rest of the dispatcher
|
|
164
|
+
SDK so consumer-domain admin tools can import directly:
|
|
165
|
+
`import { listDeadLetters, resolveDeadLetter } from "@sguild/dispatcher"`.
|
|
166
|
+
|
|
167
|
+
## Phase 2 status: complete
|
|
168
|
+
|
|
169
|
+
All five Phase 2 slices landed. The dispatcher SDK is production-ready
|
|
170
|
+
on the producer side (transactional emit, envelope and payload
|
|
171
|
+
validation), the consumer side (polling worker with LISTEN/NOTIFY
|
|
172
|
+
wake-up, dedup, retry-with-jitter, dead-letter on exhaustion), and the
|
|
173
|
+
operator surface (DLQ read and resolve API). Phase 3 (docs, observability
|
|
174
|
+
hooks, Coaching cut-over migration guide, reference implementations)
|
|
175
|
+
ships by 2026-06-29 per the Q2 directive.
|
|
176
|
+
|
|
177
|
+
(Slice 4, originally "wire subscribe", landed inside Slice 3 since the
|
|
178
|
+
wiring was a one-line change once the ConsumerLoop existed.)
|
|
179
|
+
|
|
180
|
+
**Phase 3 (consumer enablement): complete in repo.** Observability hooks
|
|
181
|
+
and the first docs set landed in this slice:
|
|
182
|
+
|
|
183
|
+
- `lib/dispatcher/observability.ts` exposes `configureDispatcherObservability`.
|
|
184
|
+
- Publish path increments `dispatcher.publish.count`.
|
|
185
|
+
- Consumer path increments `dispatcher.consume.count`, `dispatcher.dedup_hit.count`,
|
|
186
|
+
and `dispatcher.dead_letter.count`.
|
|
187
|
+
- Consumer path observes `dispatcher.end_to_end_latency_ms` and
|
|
188
|
+
`dispatcher.handler_latency_ms`.
|
|
189
|
+
- Docs:
|
|
190
|
+
- `docs/dispatcher/phase-3-observability.md`
|
|
191
|
+
- `docs/dispatcher/coaching-cutover-guide.md`
|
|
192
|
+
- `docs/dispatcher/reference-implementations.md`
|
|
193
|
+
|
|
194
|
+
## Consumer fallback
|
|
195
|
+
|
|
196
|
+
The dispatcher is now the preferred cross-domain event path. Domains may
|
|
197
|
+
keep synchronous producer API reads as an operational fallback during
|
|
198
|
+
their cutover window, but new durable cross-domain event consumption
|
|
199
|
+
should use the SDK so cursoring, dedup, DLQ, and observability all land
|
|
200
|
+
on the same rail.
|
|
201
|
+
|
|
202
|
+
## Current deliverables
|
|
203
|
+
|
|
204
|
+
- Public SDK surface: `dispatcher.publish`, `dispatcher.subscribe`,
|
|
205
|
+
`dispatcher.start`, `dispatcher.stop`, config helpers, typed envelopes,
|
|
206
|
+
registry lookups, and error classes.
|
|
207
|
+
- Producer path: transactional Postgres insert, envelope construction,
|
|
208
|
+
envelope validation, payload validation, and LISTEN/NOTIFY wake-up.
|
|
209
|
+
- Consumer path: cursoring, dedup, retry with jitter, dead-letter on
|
|
210
|
+
retry exhaustion, and graceful process drain.
|
|
211
|
+
- Operator path: DLQ list, read, and resolve helpers plus HTTP routes.
|
|
212
|
+
- Observability path: vendor-neutral counter and histogram hooks for
|
|
213
|
+
publish, consume, dedup hit, dead-letter, end-to-end latency, and
|
|
214
|
+
handler latency.
|
|
215
|
+
- Enablement docs: Phase 3 metrics wiring, Coaching cutover guide,
|
|
216
|
+
Revenue emit reference, and Coaching projection subscriber reference.
|
|
217
|
+
|
|
218
|
+
Per-event-type payload schemas still land with the owning contract as
|
|
219
|
+
each event_type acquires a binding consumer.
|
|
220
|
+
|
|
221
|
+
## Installing and consuming
|
|
222
|
+
|
|
223
|
+
The SDK ships as `@sguild/dispatcher`. A consuming domain adds it as a
|
|
224
|
+
dependency and imports from the package root:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
import { dispatcher, configureDispatcher } from "@sguild/dispatcher";
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`@prisma/client` is a **peer dependency**. The SDK does not import any
|
|
231
|
+
domain's generated Prisma client directly; the consuming domain injects
|
|
232
|
+
its own at startup (see "Usage"). Each domain owns its `DispatcherEvent`,
|
|
233
|
+
`DispatcherCursor`, `DispatcherDedup`, and `DispatcherDeadLetter` models
|
|
234
|
+
and migration per ADR-0009's per-domain table family.
|
|
235
|
+
|
|
236
|
+
The event-type registry and the JSON Schemas are bundled inside the
|
|
237
|
+
package (`contracts/`), so payload and envelope validation work at runtime
|
|
238
|
+
with no dependency on a sibling coordination repo. `registry.ts` and
|
|
239
|
+
`validator.ts` resolve the bundled copy relative to the module location;
|
|
240
|
+
a checkout that prefers a live coordination repo can still pass an
|
|
241
|
+
explicit path to `loadRegistry`, and a cwd-relative `../coordination/...`
|
|
242
|
+
fallback is tried last.
|
|
243
|
+
|
|
244
|
+
Building and publishing happen from this directory:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
npm run build # tsc -> dist/ (JS + .d.ts)
|
|
248
|
+
npm publish # prepublishOnly runs the build; ships dist/ + contracts/
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`dist/` is git-ignored; it is a build artifact produced by
|
|
252
|
+
`prepublishOnly`. The platform app itself continues to consume the SDK
|
|
253
|
+
from source via the `@/lib/dispatcher` path alias, so the nested
|
|
254
|
+
`package.json` is publish metadata and does not introduce a separate
|
|
255
|
+
`node_modules` for in-repo development.
|
|
256
|
+
|
|
257
|
+
## Where the SDK code lives
|
|
258
|
+
|
|
259
|
+
The build plan named two options: a new repo
|
|
260
|
+
`github.com/sguild-admin/dispatcher` published to npm as
|
|
261
|
+
`@sguild/dispatcher`, or a module inside the platform repo at
|
|
262
|
+
`lib/dispatcher/`. The resolved decision (see
|
|
263
|
+
`coordination/memos/2026/2026-05-14-platform-dispatcher-producer-sdk-consumption.md`):
|
|
264
|
+
the SDK is packaged and published as `@sguild/dispatcher` **from this
|
|
265
|
+
directory in the platform repo**. Domains consume the published package
|
|
266
|
+
rather than vendoring a copy.
|
|
267
|
+
|
|
268
|
+
The directory is a self-contained, publishable package: its own
|
|
269
|
+
`package.json` (name, exports map, dependencies, `@prisma/client` peer
|
|
270
|
+
dependency) and `tsconfig.json` (CJS + `.d.ts` build to `dist/`). The
|
|
271
|
+
public surface in `index.ts` is the API contract; the internal file
|
|
272
|
+
layout is malleable. Extraction to a standalone repo later, if ever
|
|
273
|
+
warranted, stays a mechanical move because the package boundary is
|
|
274
|
+
already drawn here.
|
|
275
|
+
|
|
276
|
+
## Usage
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { dispatcher, configureDispatcher, type EventEnvelope } from "@sguild/dispatcher";
|
|
280
|
+
import { prisma } from "./db/prisma"; // the consuming domain's own client
|
|
281
|
+
|
|
282
|
+
// Startup: configure once. `prismaClient` is the default client used by
|
|
283
|
+
// publishes that do not pass a per-call `tx`. The SDK has @prisma/client
|
|
284
|
+
// as a peer dependency and never imports a domain's generated client
|
|
285
|
+
// directly, so the domain injects its own here.
|
|
286
|
+
configureDispatcher({
|
|
287
|
+
producer: "revenue",
|
|
288
|
+
tenantId: "tnt_sguild",
|
|
289
|
+
prismaClient: prisma,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Producer, transactional: the event row inserts in the SAME transaction
|
|
293
|
+
// as the domain write (the producer-transactional-guarantee per ADR-0009).
|
|
294
|
+
await prisma.$transaction(async (tx) => {
|
|
295
|
+
await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
|
|
296
|
+
await dispatcher.publish(
|
|
297
|
+
{
|
|
298
|
+
event_type: "credit.locked",
|
|
299
|
+
payload: {
|
|
300
|
+
credit_reservation_id: "crr_...",
|
|
301
|
+
lesson_id: "les_...",
|
|
302
|
+
person_id: "per_...",
|
|
303
|
+
locked_credits: 6,
|
|
304
|
+
locked_at: new Date().toISOString(),
|
|
305
|
+
},
|
|
306
|
+
subject: "per_...",
|
|
307
|
+
actor: "system:revenue",
|
|
308
|
+
},
|
|
309
|
+
{ tx },
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Producer, no domain write to coordinate with: omit `tx` and the publish
|
|
314
|
+
// runs against the injected `prismaClient` in its own transaction.
|
|
315
|
+
await dispatcher.publish({
|
|
316
|
+
event_type: "credit.locked",
|
|
317
|
+
payload: { /* ... */ },
|
|
318
|
+
subject: "per_...",
|
|
319
|
+
actor: "system:revenue",
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Consumer
|
|
323
|
+
type CreditLockedPayload = {
|
|
324
|
+
credit_reservation_id: string;
|
|
325
|
+
lesson_id: string;
|
|
326
|
+
person_id: string;
|
|
327
|
+
locked_credits: number;
|
|
328
|
+
locked_at: string;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
dispatcher.subscribe<CreditLockedPayload>("credit.locked", async (event) => {
|
|
332
|
+
const { lesson_id, locked_at } = event.payload;
|
|
333
|
+
// ... Coaching's availability projection update, etc.
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Standards alignment
|
|
338
|
+
|
|
339
|
+
This module is cross-cutting infrastructure per
|
|
340
|
+
`coordination/standards/engineering/module-layout.md` §1, so it lives at
|
|
341
|
+
`platform/lib/dispatcher/` rather than `platform/modules/dispatcher/`.
|
|
342
|
+
The per-domain six-file module layout (dto, schema, repo, service,
|
|
343
|
+
route, index, optional actions) does not apply; the dispatcher does
|
|
344
|
+
not own a domain object. Internal file organization (types, registry,
|
|
345
|
+
dispatcher, transport-stubs) is dispatcher-specific.
|
|
346
|
+
|
|
347
|
+
## Related artifacts
|
|
348
|
+
|
|
349
|
+
- Build plan: `coordination/memos/2026/2026-05-01-platform-dispatcher-sdk-build-plan.md`
|
|
350
|
+
- Gap memo (origin of the SDK conversation): `coordination/memos/2026/2026-05-01-platform-dispatcher-sdk-gap-and-interim-shape.md`
|
|
351
|
+
- Event envelope contract: `coordination/contracts/event-envelope/README.md`
|
|
352
|
+
- ADR-0005 (event envelope decision): `coordination/adrs/ADR-0005-event-envelope.md`
|
|
353
|
+
- ADR-0009 (bus choice; pending): tracked on the build plan thread
|
|
354
|
+
- Platform-owed ledger: `coordination/memos/2026/2026-05-01-platform-owed-ledger.md`
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/credit-reservation-funding-state/schema/payloads/reservation.funded-v1.json",
|
|
4
|
+
"title": "reservation.funded payload v1",
|
|
5
|
+
"description": "Payload for the reservation.funded event_type per contracts/credit-reservation-funding-state/README.md §4.1. Emitted by Revenue at the writeback transaction commit when a credit reservation's funding sub-state transitions from pending_funding to funded. Producer SHALL emit inside the same Prisma transaction as the credit-reservation funding-status update per ADR-0009. Subscribers: revenue (reconciliation), platform-warehouse.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"credit_reservation_id",
|
|
10
|
+
"person_id",
|
|
11
|
+
"funding_source",
|
|
12
|
+
"payment_processor_provider",
|
|
13
|
+
"payment_processor_ref",
|
|
14
|
+
"funded_at"
|
|
15
|
+
],
|
|
16
|
+
"properties": {
|
|
17
|
+
"credit_reservation_id": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "The Revenue-owned Credit Reservation whose funding sub-state transitioned to funded. crr_<UUID v7>.",
|
|
20
|
+
"pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
21
|
+
},
|
|
22
|
+
"person_id": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The Person whose reservation this is. Sourced from the reservation's owning credit-account's client per the Identity canonical surface. per_<UUID v7>.",
|
|
25
|
+
"pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
26
|
+
},
|
|
27
|
+
"funding_source": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Path through which the reservation became funded per §4.4. Consumers SHALL treat unknown values as safe-to-ignore per §8.",
|
|
30
|
+
"enum": [
|
|
31
|
+
"invoice_paid",
|
|
32
|
+
"active_charge",
|
|
33
|
+
"cash",
|
|
34
|
+
"check",
|
|
35
|
+
"credit_balance",
|
|
36
|
+
"refund_recovery"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"payment_processor_provider": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Provider attribution per the funding-state external-reference rule. `manual` for sources without a provider call (cash, check, credit_balance).",
|
|
42
|
+
"enum": [
|
|
43
|
+
"square",
|
|
44
|
+
"stripe",
|
|
45
|
+
"manual"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"payment_processor_ref": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "Provider-side identifier linking this funding-state transition back to the payment provider's record. Square payment id (sqpay_<id>) for square provider; Stripe payment intent id (pi_<id>) for stripe provider; external-actions row id prefixed with ext_ for manual provider.",
|
|
51
|
+
"minLength": 1
|
|
52
|
+
},
|
|
53
|
+
"funded_at": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"format": "date-time",
|
|
56
|
+
"description": "Wall-clock UTC timestamp at the writeback transaction commit. Producer's clock."
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/credit-reservation-funding-state/schema/payloads/reservation.refunded-v1.json",
|
|
4
|
+
"title": "reservation.refunded payload v1",
|
|
5
|
+
"description": "Payload for the reservation.refunded event_type per contracts/credit-reservation-funding-state/README.md §4.3. Emitted by Revenue at the writeback transaction commit when a credit reservation's funding sub-state transitions from refunding to refunded. Producer SHALL emit inside the same Prisma transaction as the credit-reservation funding-status update per ADR-0009. Subscribers: revenue (reconciliation), platform-warehouse.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"credit_reservation_id",
|
|
10
|
+
"person_id",
|
|
11
|
+
"refund_reason",
|
|
12
|
+
"payment_processor_provider",
|
|
13
|
+
"payment_processor_ref",
|
|
14
|
+
"refund_amount_cents",
|
|
15
|
+
"currency",
|
|
16
|
+
"refunding_at",
|
|
17
|
+
"refunded_at"
|
|
18
|
+
],
|
|
19
|
+
"properties": {
|
|
20
|
+
"credit_reservation_id": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "The Revenue-owned Credit Reservation whose funding sub-state transitioned to refunded. crr_<UUID v7>.",
|
|
23
|
+
"pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
24
|
+
},
|
|
25
|
+
"person_id": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "The Person whose reservation this is. Sourced from the reservation's owning credit-account's client per the Identity canonical surface. per_<UUID v7>.",
|
|
28
|
+
"pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
29
|
+
},
|
|
30
|
+
"refund_reason": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Cancellation or operator reason that triggered the refund-flow against this reservation's funded balance. Mirrors credit.released v2 reason_code per §4.5.",
|
|
33
|
+
"enum": [
|
|
34
|
+
"site_closure",
|
|
35
|
+
"coach_unavailable_reschedule_failed",
|
|
36
|
+
"force_majeure",
|
|
37
|
+
"weather",
|
|
38
|
+
"administrative_void",
|
|
39
|
+
"customer_requested_in_window",
|
|
40
|
+
"customer_requested_exception",
|
|
41
|
+
"policy_exception",
|
|
42
|
+
"bad_debt_writeoff"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"payment_processor_provider": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Provider attribution per the funding-state external-reference rule.",
|
|
48
|
+
"enum": [
|
|
49
|
+
"square",
|
|
50
|
+
"stripe",
|
|
51
|
+
"manual"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"payment_processor_ref": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "Provider-side identifier linking this funding-state transition back to the refund provider's record. Square refund id for square provider; Stripe refund id for stripe provider; external-actions row id prefixed with ext_ for manual provider.",
|
|
57
|
+
"minLength": 1
|
|
58
|
+
},
|
|
59
|
+
"refund_amount_cents": {
|
|
60
|
+
"type": "integer",
|
|
61
|
+
"minimum": 0,
|
|
62
|
+
"description": "Refund amount in minor currency units for this reservation's funding-state transition."
|
|
63
|
+
},
|
|
64
|
+
"currency": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"description": "ISO 4217 currency code.",
|
|
67
|
+
"pattern": "^[A-Z]{3}$"
|
|
68
|
+
},
|
|
69
|
+
"refunding_at": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"format": "date-time",
|
|
72
|
+
"description": "Wall-clock UTC timestamp from the matching reservation.refunding event. The producer preserves this value across the refunding/refunded pair."
|
|
73
|
+
},
|
|
74
|
+
"refunded_at": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"format": "date-time",
|
|
77
|
+
"description": "Wall-clock UTC timestamp at the refunded writeback transaction commit. Producer's clock."
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
package/contracts/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/credit-reservation-funding-state/schema/payloads/reservation.refunding-v1.json",
|
|
4
|
+
"title": "reservation.refunding payload v1",
|
|
5
|
+
"description": "Payload for the reservation.refunding event_type per contracts/credit-reservation-funding-state/README.md §4.2. Emitted by Revenue at the writeback transaction commit when a credit reservation's funding sub-state transitions from funded to refunding. Producer SHALL emit inside the same Prisma transaction as the credit-reservation funding-status update per ADR-0009. Subscribers: revenue (reconciliation), platform-warehouse.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"credit_reservation_id",
|
|
10
|
+
"person_id",
|
|
11
|
+
"refund_reason",
|
|
12
|
+
"payment_processor_provider",
|
|
13
|
+
"payment_processor_ref",
|
|
14
|
+
"refund_amount_cents",
|
|
15
|
+
"currency",
|
|
16
|
+
"refunding_at"
|
|
17
|
+
],
|
|
18
|
+
"properties": {
|
|
19
|
+
"credit_reservation_id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "The Revenue-owned Credit Reservation whose funding sub-state transitioned to refunding. crr_<UUID v7>.",
|
|
22
|
+
"pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
23
|
+
},
|
|
24
|
+
"person_id": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "The Person whose reservation this is. Sourced from the reservation's owning credit-account's client per the Identity canonical surface. per_<UUID v7>.",
|
|
27
|
+
"pattern": "^per_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
28
|
+
},
|
|
29
|
+
"refund_reason": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Cancellation or operator reason that triggered the refund-flow against this reservation's funded balance. Mirrors credit.released v2 reason_code per §4.5.",
|
|
32
|
+
"enum": [
|
|
33
|
+
"site_closure",
|
|
34
|
+
"coach_unavailable_reschedule_failed",
|
|
35
|
+
"force_majeure",
|
|
36
|
+
"weather",
|
|
37
|
+
"administrative_void",
|
|
38
|
+
"customer_requested_in_window",
|
|
39
|
+
"customer_requested_exception",
|
|
40
|
+
"policy_exception",
|
|
41
|
+
"bad_debt_writeoff"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"payment_processor_provider": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Provider attribution per the funding-state external-reference rule.",
|
|
47
|
+
"enum": [
|
|
48
|
+
"square",
|
|
49
|
+
"stripe",
|
|
50
|
+
"manual"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
"payment_processor_ref": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Provider-side identifier linking this funding-state transition back to the refund provider's record. Square refund id for square provider; Stripe refund id for stripe provider; external-actions row id prefixed with ext_ for manual provider.",
|
|
56
|
+
"minLength": 1
|
|
57
|
+
},
|
|
58
|
+
"refund_amount_cents": {
|
|
59
|
+
"type": "integer",
|
|
60
|
+
"minimum": 0,
|
|
61
|
+
"description": "Refund amount in minor currency units for this reservation's funding-state transition."
|
|
62
|
+
},
|
|
63
|
+
"currency": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "ISO 4217 currency code.",
|
|
66
|
+
"pattern": "^[A-Z]{3}$"
|
|
67
|
+
},
|
|
68
|
+
"refunding_at": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"format": "date-time",
|
|
71
|
+
"description": "Wall-clock UTC timestamp at the refunding writeback transaction commit. Producer's clock."
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/credit-reservation-lock/schema/payloads/credit.consumed-v1.json",
|
|
4
|
+
"title": "credit.consumed payload v1",
|
|
5
|
+
"description": "Payload for the credit.consumed event_type per contracts/credit-reservation-lock/README.md §9.5. Emitted by Revenue at lesson delivery (locked → consumed transition). Subscribers: Delivery (post-delivery follow-up), Revenue (revenue recognition), Platform warehouse. Coaching does NOT subscribe; consumed transitions are terminal-past and do not bear on future-window availability.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"credit_reservation_id",
|
|
10
|
+
"lesson_id",
|
|
11
|
+
"consumed_credits",
|
|
12
|
+
"consumed_at"
|
|
13
|
+
],
|
|
14
|
+
"properties": {
|
|
15
|
+
"credit_reservation_id": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
18
|
+
},
|
|
19
|
+
"lesson_id": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"pattern": "^les_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
22
|
+
},
|
|
23
|
+
"consumed_credits": {
|
|
24
|
+
"type": "integer",
|
|
25
|
+
"description": "N (positive integer): the credit count posted as the Lesson Debit ledger entry per §7.",
|
|
26
|
+
"minimum": 1
|
|
27
|
+
},
|
|
28
|
+
"consumed_at": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"format": "date-time"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://contracts.sguild/credit-reservation-lock/schema/payloads/credit.forfeited-v1.json",
|
|
4
|
+
"title": "credit.forfeited payload v1",
|
|
5
|
+
"description": "Payload for the credit.forfeited event_type per contracts/credit-reservation-lock/README.md §9.7. Emitted by Revenue on locked → forfeited transition (customer-side cancellation post-lock or no-show). Subscribers: Delivery (re-engagement workflow), Revenue (recognition of forfeited credits as recognized revenue per policy), Coaching (unsubtracts the slot in the availability projection per coach-availability §4.3.2), Platform warehouse. Forfeiture is always customer-side per §9.7; Sguild-initiated cancellations emit credit.released, never credit.forfeited.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": [
|
|
9
|
+
"credit_reservation_id",
|
|
10
|
+
"lesson_id",
|
|
11
|
+
"forfeited_credits",
|
|
12
|
+
"forfeiture_reason",
|
|
13
|
+
"forfeited_at"
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"credit_reservation_id": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"pattern": "^crr_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
19
|
+
},
|
|
20
|
+
"lesson_id": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"pattern": "^les_[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
23
|
+
},
|
|
24
|
+
"forfeited_credits": {
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"description": "N (positive integer): the credit count posted as the Credit Forfeit ledger entry per §7.",
|
|
27
|
+
"minimum": 1
|
|
28
|
+
},
|
|
29
|
+
"forfeiture_reason": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"enum": [
|
|
32
|
+
"late_cancel",
|
|
33
|
+
"no_show"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"forfeited_at": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"format": "date-time"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|