@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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Postgres-queue transport for the dispatcher SDK.
|
|
4
|
+
*
|
|
5
|
+
* Implements the publish-side path of the bus dispatcher per ADR-0009
|
|
6
|
+
* §"Decision". The producer-side transactional guarantee is the
|
|
7
|
+
* load-bearing primitive: producers call `dispatcher.publish` inside a
|
|
8
|
+
* Prisma transaction, the SDK inserts the event row in the SAME
|
|
9
|
+
* transaction (via the `tx` argument), and either both the domain write
|
|
10
|
+
* and the event emit commit or both roll back. No outbox pattern, no
|
|
11
|
+
* staging table, no second state machine to debug; this is the property
|
|
12
|
+
* that breaks the tie among the four bus options ADR-0009 considered.
|
|
13
|
+
*
|
|
14
|
+
* Phase 2 Slice 2 (this file): publish path. Slice 3 lands the consumer
|
|
15
|
+
* polling worker plus LISTEN/NOTIFY listener; Slice 4 wires this into
|
|
16
|
+
* `dispatcher.ts`'s public surface (replacing the
|
|
17
|
+
* DispatcherNotImplementedError stubs); Slice 5 ships the per-consumer
|
|
18
|
+
* DLQ read API.
|
|
19
|
+
*
|
|
20
|
+
* Usage shape producers will adopt at their state-transition call sites:
|
|
21
|
+
*
|
|
22
|
+
* await prisma.$transaction(async (tx) => {
|
|
23
|
+
* // ... domain write that produces the event ...
|
|
24
|
+
* await tx.creditReservation.update({ where: { id }, data: { state: "locked" } });
|
|
25
|
+
*
|
|
26
|
+
* // ... emit the event in the SAME transaction ...
|
|
27
|
+
* await dispatcher.publish({
|
|
28
|
+
* event_type: "credit.locked",
|
|
29
|
+
* payload: { credit_reservation_id: id, ... },
|
|
30
|
+
* subject: personId,
|
|
31
|
+
* actor: "system:revenue",
|
|
32
|
+
* }, { tx });
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* Without the `tx` option, publish runs in its own transaction off the
|
|
36
|
+
* default Prisma client. That's the right shape for emit paths that have
|
|
37
|
+
* no domain write to coordinate with (e.g., a periodic job that emits a
|
|
38
|
+
* status event); it preserves the at-most-once-insert semantics but
|
|
39
|
+
* without coupling to a domain write.
|
|
40
|
+
*/
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.EVENT_ID_PREFIX = void 0;
|
|
43
|
+
exports.publishToPostgres = publishToPostgres;
|
|
44
|
+
const id_1 = require("./internal/id");
|
|
45
|
+
const config_1 = require("./config");
|
|
46
|
+
const dispatcher_errors_1 = require("./dispatcher-errors");
|
|
47
|
+
const observability_1 = require("./observability");
|
|
48
|
+
const registry_1 = require("./registry");
|
|
49
|
+
const validator_1 = require("./validator");
|
|
50
|
+
/**
|
|
51
|
+
* ID prefix for envelope event_id values per envelope contract §4.1
|
|
52
|
+
* and ADR-0002. `evt_<UUID v7 canonical>` is the canonical shape.
|
|
53
|
+
*/
|
|
54
|
+
exports.EVENT_ID_PREFIX = "evt_";
|
|
55
|
+
/**
|
|
56
|
+
* Publish an event to the Postgres-queue transport. Returns the canonical
|
|
57
|
+
* event_id so the producer can correlate retries per envelope contract
|
|
58
|
+
* §10.6 (producers SHALL reuse the same event_id on retry so consumer
|
|
59
|
+
* dedup works).
|
|
60
|
+
*
|
|
61
|
+
* Validation order: registry lookup → schema_version resolution →
|
|
62
|
+
* envelope construction → envelope JSON Schema validation → payload
|
|
63
|
+
* validation → INSERT. A failure at any step throws before the row
|
|
64
|
+
* lands, so the dispatcher_event table never carries unschema'd or
|
|
65
|
+
* unregistered events.
|
|
66
|
+
*/
|
|
67
|
+
async function publishToPostgres(emit, options = {}) {
|
|
68
|
+
// 1. Registry lookup.
|
|
69
|
+
const registration = (0, registry_1.getEventTypeRegistration)(emit.event_type);
|
|
70
|
+
if (!registration) {
|
|
71
|
+
throw new dispatcher_errors_1.UnregisteredEventTypeError(emit.event_type);
|
|
72
|
+
}
|
|
73
|
+
// 2. Schema_version resolution. Caller-supplied wins; fall back to
|
|
74
|
+
// the active version registered for this event_type.
|
|
75
|
+
const targetVersion = emit.schema_version ?? (0, registry_1.getActiveSchemaVersion)(emit.event_type)?.version ?? null;
|
|
76
|
+
if (targetVersion === null) {
|
|
77
|
+
throw new dispatcher_errors_1.UnregisteredSchemaVersionError(emit.event_type, null);
|
|
78
|
+
}
|
|
79
|
+
const versionEntry = registration.schema_versions.find((v) => v.version === targetVersion);
|
|
80
|
+
if (!versionEntry) {
|
|
81
|
+
throw new dispatcher_errors_1.UnregisteredSchemaVersionError(emit.event_type, targetVersion);
|
|
82
|
+
}
|
|
83
|
+
// 3. Envelope construction. Auto-populate the SDK-controlled fields
|
|
84
|
+
// per envelope contract §10.2; producer supplies the rest.
|
|
85
|
+
const config = (0, config_1.getDispatcherConfig)();
|
|
86
|
+
const envelope = {
|
|
87
|
+
event_id: `${exports.EVENT_ID_PREFIX}${(0, id_1.uuidv7)()}`,
|
|
88
|
+
event_type: emit.event_type,
|
|
89
|
+
occurred_at: new Date().toISOString(),
|
|
90
|
+
tenant_id: config.tenantId,
|
|
91
|
+
producer: config.producer,
|
|
92
|
+
schema_version: targetVersion,
|
|
93
|
+
payload: emit.payload,
|
|
94
|
+
...(emit.subject ? { subject: emit.subject } : {}),
|
|
95
|
+
...(emit.actor ? { actor: emit.actor } : {}),
|
|
96
|
+
...(emit.correlation_id ? { correlation_id: emit.correlation_id } : {}),
|
|
97
|
+
};
|
|
98
|
+
// 4. Envelope JSON Schema validation. Catches shape errors at the
|
|
99
|
+
// SDK boundary, before the row hits the table.
|
|
100
|
+
(0, validator_1.validateEnvelope)(envelope);
|
|
101
|
+
// 5. Payload JSON Schema validation. Per ADR-0009 action item 8:
|
|
102
|
+
// every emit validates against the registered schema for the
|
|
103
|
+
// (event_type, schema_version) tuple. Skip if the registry has no
|
|
104
|
+
// payload_schema recorded; the registry-hygiene story per the
|
|
105
|
+
// Phase 0 wrap is that authorship belongs to the owning domain
|
|
106
|
+
// and pending entries are not yet validate-blocking.
|
|
107
|
+
if (versionEntry.payload_schema !== null) {
|
|
108
|
+
(0, validator_1.validatePayload)(emit.event_type, targetVersion, emit.payload);
|
|
109
|
+
}
|
|
110
|
+
// 6. INSERT into dispatcher_event using the caller's tx if supplied,
|
|
111
|
+
// otherwise the Prisma client injected at startup via
|
|
112
|
+
// configureDispatcher({ prismaClient }). The same-transaction insert
|
|
113
|
+
// is the producer-transactional-guarantee primitive ADR-0009 chose
|
|
114
|
+
// Postgres for. The SDK does not import any domain's generated client
|
|
115
|
+
// directly; the consuming domain injects its own (@prisma/client is a
|
|
116
|
+
// peer dependency).
|
|
117
|
+
const db = options.tx ?? config.prismaClient;
|
|
118
|
+
if (!db) {
|
|
119
|
+
throw new Error("dispatcher.publish: no Prisma client available for the insert. Either pass " +
|
|
120
|
+
"{ tx } to publish() with an interactive-transaction client, or call " +
|
|
121
|
+
"configureDispatcher({ producer, tenantId, prismaClient }) at process startup " +
|
|
122
|
+
"so the publish path has a default client.");
|
|
123
|
+
}
|
|
124
|
+
await db.dispatcherEvent.create({
|
|
125
|
+
data: {
|
|
126
|
+
id: envelope.event_id,
|
|
127
|
+
eventType: envelope.event_type,
|
|
128
|
+
schemaVersion: envelope.schema_version,
|
|
129
|
+
tenantId: envelope.tenant_id,
|
|
130
|
+
producer: envelope.producer,
|
|
131
|
+
subject: envelope.subject ?? null,
|
|
132
|
+
actor: envelope.actor ?? "",
|
|
133
|
+
occurredAt: new Date(envelope.occurred_at),
|
|
134
|
+
payload: envelope.payload,
|
|
135
|
+
...(envelope.correlation_id ? { correlationId: envelope.correlation_id } : {}),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
(0, observability_1.recordDispatcherIncrement)("dispatcher.publish.count", {
|
|
139
|
+
event_type: envelope.event_type,
|
|
140
|
+
producer: envelope.producer,
|
|
141
|
+
schema_version: String(envelope.schema_version),
|
|
142
|
+
});
|
|
143
|
+
return { event_id: envelope.event_id };
|
|
144
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Producer-DB resolution for the fanout worker.
|
|
3
|
+
*
|
|
4
|
+
* Per ADR-0009 §"Per-domain table family," each producer-domain owns its
|
|
5
|
+
* own `dispatcher_event` / `dispatcher_cursor` / `dispatcher_dedup` /
|
|
6
|
+
* `dispatcher_dead_letter` tables in the producer's database. Cross-DB
|
|
7
|
+
* transactions are not supported, so the producer-side write to
|
|
8
|
+
* `dispatcher_event` happens in the producer's DB and any reader of that
|
|
9
|
+
* stream needs a connection there.
|
|
10
|
+
*
|
|
11
|
+
* Topology B (per 2026-05-12-platform-cross-db-consumer-dsn-upstream):
|
|
12
|
+
* Platform runs one fanout worker that polls every producer's
|
|
13
|
+
* `dispatcher_event` and POSTs envelopes to the registered consumer
|
|
14
|
+
* webhook inboxes. Centralizing here means the cross-DB read happens in
|
|
15
|
+
* exactly one place (the worker), not in N consumer processes. This file
|
|
16
|
+
* resolves "producer name" -> "pg.Pool pointed at that producer's DB"
|
|
17
|
+
* for the worker.
|
|
18
|
+
*
|
|
19
|
+
* Why pg.Pool instead of PrismaClient: Platform's Prisma client is
|
|
20
|
+
* generated against Platform's schema.prisma, which has no
|
|
21
|
+
* `multiSchema` or `@@schema` declarations and so always qualifies
|
|
22
|
+
* dispatcher_X queries with `public.`. Producer DBs (Growth, Sales,
|
|
23
|
+
* etc.) put their dispatcher tables in domain-specific schemas (Sales'
|
|
24
|
+
* `sales` schema, Growth's `growth` schema via `?schema=` URL param).
|
|
25
|
+
* Platform's `public.`-qualified queries fail there. Raw pg.Pool +
|
|
26
|
+
* unqualified SQL + the connection's search_path (derived from the DSN's
|
|
27
|
+
* `?schema=<domain>` query param by pgConfigWithSearchPath) resolves the
|
|
28
|
+
* schema correctly per producer with zero code per producer.
|
|
29
|
+
*
|
|
30
|
+
* Env var convention:
|
|
31
|
+
*
|
|
32
|
+
* - For the worker's own producer (`platform`): DATABASE_URL.
|
|
33
|
+
* - For every other producer: `DISPATCHER_PRODUCER_DATABASE_URL_<PRODUCER>`
|
|
34
|
+
* e.g., DISPATCHER_PRODUCER_DATABASE_URL_GROWTH points at Growth's
|
|
35
|
+
* `sguild-domains` Supabase project pooler URL with
|
|
36
|
+
* `?schema=growth` so unqualified `dispatcher_cursor` queries
|
|
37
|
+
* resolve to `growth.dispatcher_cursor`.
|
|
38
|
+
*
|
|
39
|
+
* A missing DSN for a producer logs a warning and excludes that
|
|
40
|
+
* producer from the fanout boot loop (sibling pattern to a missing
|
|
41
|
+
* consumer URL excluding that consumer-domain). Operators see the gap in
|
|
42
|
+
* the boot log and provision the env var; the cursor for any
|
|
43
|
+
* (consumer, event_type) reading from that producer stays put until the
|
|
44
|
+
* env arrives.
|
|
45
|
+
*
|
|
46
|
+
* Lifecycle: pools are cached by producer name for process lifetime.
|
|
47
|
+
* fanout-worker.ts's SIGTERM handler closes the ConsumerLoops which
|
|
48
|
+
* close pools they own; pools handed out here are NOT owned by the
|
|
49
|
+
* loops (multiple loops can share a pool against the same producer DB).
|
|
50
|
+
* The cached pools rely on Node process exit to drain.
|
|
51
|
+
*/
|
|
52
|
+
import { Pool } from "pg";
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a producer name to its DSN. Returns undefined if the env var
|
|
55
|
+
* is not set; in that case the fanout boot loop logs and skips this
|
|
56
|
+
* producer.
|
|
57
|
+
*/
|
|
58
|
+
export declare function resolveProducerDsn(producer: string): string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Resolve a producer name to a pg.Pool + databaseUrl pointed at that
|
|
61
|
+
* producer's DB. Returns undefined if the DSN is missing (the caller
|
|
62
|
+
* logs and skips).
|
|
63
|
+
*
|
|
64
|
+
* Pools are cached by producer name so multiple loops reading the same
|
|
65
|
+
* producer share a single pool. Pool size defaults to pg's default
|
|
66
|
+
* (10 connections); for the dispatcher fanout that's well within
|
|
67
|
+
* typical Supabase pooler limits.
|
|
68
|
+
*/
|
|
69
|
+
export declare function getProducerDb(producer: string): {
|
|
70
|
+
pool: Pool;
|
|
71
|
+
databaseUrl: string;
|
|
72
|
+
schema: string | null;
|
|
73
|
+
} | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* Test-only seam: clear the cache so each test boots the resolver from
|
|
76
|
+
* scratch. Production code MUST NOT call this; it would leak the
|
|
77
|
+
* existing pool connections (the old pools stay open and the new ones
|
|
78
|
+
* start from zero).
|
|
79
|
+
*/
|
|
80
|
+
export declare function __resetProducerDbCacheForTests(): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Producer-DB resolution for the fanout worker.
|
|
4
|
+
*
|
|
5
|
+
* Per ADR-0009 §"Per-domain table family," each producer-domain owns its
|
|
6
|
+
* own `dispatcher_event` / `dispatcher_cursor` / `dispatcher_dedup` /
|
|
7
|
+
* `dispatcher_dead_letter` tables in the producer's database. Cross-DB
|
|
8
|
+
* transactions are not supported, so the producer-side write to
|
|
9
|
+
* `dispatcher_event` happens in the producer's DB and any reader of that
|
|
10
|
+
* stream needs a connection there.
|
|
11
|
+
*
|
|
12
|
+
* Topology B (per 2026-05-12-platform-cross-db-consumer-dsn-upstream):
|
|
13
|
+
* Platform runs one fanout worker that polls every producer's
|
|
14
|
+
* `dispatcher_event` and POSTs envelopes to the registered consumer
|
|
15
|
+
* webhook inboxes. Centralizing here means the cross-DB read happens in
|
|
16
|
+
* exactly one place (the worker), not in N consumer processes. This file
|
|
17
|
+
* resolves "producer name" -> "pg.Pool pointed at that producer's DB"
|
|
18
|
+
* for the worker.
|
|
19
|
+
*
|
|
20
|
+
* Why pg.Pool instead of PrismaClient: Platform's Prisma client is
|
|
21
|
+
* generated against Platform's schema.prisma, which has no
|
|
22
|
+
* `multiSchema` or `@@schema` declarations and so always qualifies
|
|
23
|
+
* dispatcher_X queries with `public.`. Producer DBs (Growth, Sales,
|
|
24
|
+
* etc.) put their dispatcher tables in domain-specific schemas (Sales'
|
|
25
|
+
* `sales` schema, Growth's `growth` schema via `?schema=` URL param).
|
|
26
|
+
* Platform's `public.`-qualified queries fail there. Raw pg.Pool +
|
|
27
|
+
* unqualified SQL + the connection's search_path (derived from the DSN's
|
|
28
|
+
* `?schema=<domain>` query param by pgConfigWithSearchPath) resolves the
|
|
29
|
+
* schema correctly per producer with zero code per producer.
|
|
30
|
+
*
|
|
31
|
+
* Env var convention:
|
|
32
|
+
*
|
|
33
|
+
* - For the worker's own producer (`platform`): DATABASE_URL.
|
|
34
|
+
* - For every other producer: `DISPATCHER_PRODUCER_DATABASE_URL_<PRODUCER>`
|
|
35
|
+
* e.g., DISPATCHER_PRODUCER_DATABASE_URL_GROWTH points at Growth's
|
|
36
|
+
* `sguild-domains` Supabase project pooler URL with
|
|
37
|
+
* `?schema=growth` so unqualified `dispatcher_cursor` queries
|
|
38
|
+
* resolve to `growth.dispatcher_cursor`.
|
|
39
|
+
*
|
|
40
|
+
* A missing DSN for a producer logs a warning and excludes that
|
|
41
|
+
* producer from the fanout boot loop (sibling pattern to a missing
|
|
42
|
+
* consumer URL excluding that consumer-domain). Operators see the gap in
|
|
43
|
+
* the boot log and provision the env var; the cursor for any
|
|
44
|
+
* (consumer, event_type) reading from that producer stays put until the
|
|
45
|
+
* env arrives.
|
|
46
|
+
*
|
|
47
|
+
* Lifecycle: pools are cached by producer name for process lifetime.
|
|
48
|
+
* fanout-worker.ts's SIGTERM handler closes the ConsumerLoops which
|
|
49
|
+
* close pools they own; pools handed out here are NOT owned by the
|
|
50
|
+
* loops (multiple loops can share a pool against the same producer DB).
|
|
51
|
+
* The cached pools rely on Node process exit to drain.
|
|
52
|
+
*/
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
exports.resolveProducerDsn = resolveProducerDsn;
|
|
55
|
+
exports.getProducerDb = getProducerDb;
|
|
56
|
+
exports.__resetProducerDbCacheForTests = __resetProducerDbCacheForTests;
|
|
57
|
+
const pg_1 = require("pg");
|
|
58
|
+
const pg_search_path_1 = require("./internal/pg-search-path");
|
|
59
|
+
/**
|
|
60
|
+
* Cache of producer name -> { pool, databaseUrl, schema }. Keyed by producer
|
|
61
|
+
* name for fast lookup at boot. The databaseUrl is also kept so the
|
|
62
|
+
* ConsumerLoop can wire its LISTEN/NOTIFY pg.Client against the same
|
|
63
|
+
* producer's DSN. The schema is the parsed `?schema=` query param value
|
|
64
|
+
* (null if absent or `public`); ConsumerLoop schema-qualifies its SQL
|
|
65
|
+
* with this value so queries work even when pgbouncer's transaction-mode
|
|
66
|
+
* pooler drops the libpq `options` startup parameter.
|
|
67
|
+
*/
|
|
68
|
+
const cache = new Map();
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a producer name to its DSN. Returns undefined if the env var
|
|
71
|
+
* is not set; in that case the fanout boot loop logs and skips this
|
|
72
|
+
* producer.
|
|
73
|
+
*/
|
|
74
|
+
function resolveProducerDsn(producer) {
|
|
75
|
+
if (producer === "platform") {
|
|
76
|
+
return process.env.DATABASE_URL;
|
|
77
|
+
}
|
|
78
|
+
const envName = `DISPATCHER_PRODUCER_DATABASE_URL_${producer.toUpperCase()}`;
|
|
79
|
+
return process.env[envName];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Resolve a producer name to a pg.Pool + databaseUrl pointed at that
|
|
83
|
+
* producer's DB. Returns undefined if the DSN is missing (the caller
|
|
84
|
+
* logs and skips).
|
|
85
|
+
*
|
|
86
|
+
* Pools are cached by producer name so multiple loops reading the same
|
|
87
|
+
* producer share a single pool. Pool size defaults to pg's default
|
|
88
|
+
* (10 connections); for the dispatcher fanout that's well within
|
|
89
|
+
* typical Supabase pooler limits.
|
|
90
|
+
*/
|
|
91
|
+
function getProducerDb(producer) {
|
|
92
|
+
const cached = cache.get(producer);
|
|
93
|
+
if (cached)
|
|
94
|
+
return cached;
|
|
95
|
+
const dsn = resolveProducerDsn(producer);
|
|
96
|
+
if (!dsn)
|
|
97
|
+
return undefined;
|
|
98
|
+
const config = (0, pg_search_path_1.pgConfigWithSearchPath)(dsn);
|
|
99
|
+
const pool = new pg_1.Pool(config);
|
|
100
|
+
pool.on("error", (error) => {
|
|
101
|
+
console.warn(`[dispatcher.producer-db] ${producer} pool error: ${error.message}`);
|
|
102
|
+
});
|
|
103
|
+
const entry = { pool, databaseUrl: dsn, schema: config.schema };
|
|
104
|
+
cache.set(producer, entry);
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Test-only seam: clear the cache so each test boots the resolver from
|
|
109
|
+
* scratch. Production code MUST NOT call this; it would leak the
|
|
110
|
+
* existing pool connections (the old pools stay open and the new ones
|
|
111
|
+
* start from zero).
|
|
112
|
+
*/
|
|
113
|
+
function __resetProducerDbCacheForTests() {
|
|
114
|
+
cache.clear();
|
|
115
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-type registry loader and query.
|
|
3
|
+
*
|
|
4
|
+
* Reads the canonical registry manifest at
|
|
5
|
+
* coordination/contracts/event-types-registry.json (path resolved relative to
|
|
6
|
+
* the platform repo's working directory; the registry is checked into the
|
|
7
|
+
* coordination repo and the platform repo references it via its known
|
|
8
|
+
* filesystem location during local development; CI gets a copy via the same
|
|
9
|
+
* mechanism).
|
|
10
|
+
*
|
|
11
|
+
* The registry is the single source of truth for which (event_type,
|
|
12
|
+
* schema_version) pairs are valid. The dispatcher reads it at startup to drive
|
|
13
|
+
* validation; producers register new event_types here before first use per
|
|
14
|
+
* §10.4 of the envelope contract.
|
|
15
|
+
*/
|
|
16
|
+
import type { ProducerDomain } from "./types";
|
|
17
|
+
export type SchemaVersionStatus = "active" | "deprecated" | "sunset";
|
|
18
|
+
export type SchemaVersionEntry = {
|
|
19
|
+
version: number;
|
|
20
|
+
/** Path to the JSON Schema for this payload version, relative to the coordination repo root. May be null if the schema has not yet been authored. */
|
|
21
|
+
payload_schema: string | null;
|
|
22
|
+
status: SchemaVersionStatus;
|
|
23
|
+
/** YYYY-MM-DD when this version was registered. */
|
|
24
|
+
registered_at: string;
|
|
25
|
+
notes?: string;
|
|
26
|
+
};
|
|
27
|
+
export type EventTypeRegistration = {
|
|
28
|
+
event_type: string;
|
|
29
|
+
producer: ProducerDomain;
|
|
30
|
+
/**
|
|
31
|
+
* The closed-set domains that subscribe, plus the synthetic "platform-warehouse"
|
|
32
|
+
* value for the warehouse loader. Today this is informational; once CI
|
|
33
|
+
* validation lands as a Phase 0 deliverable, it gates emit-side smoke tests
|
|
34
|
+
* against unregistered consumers.
|
|
35
|
+
*/
|
|
36
|
+
consumers: Array<ProducerDomain | "platform-warehouse">;
|
|
37
|
+
/** Path to the contract that owns the producer surface. May be null for events whose owning contract has not been written yet. */
|
|
38
|
+
owning_contract: string | null;
|
|
39
|
+
schema_versions: SchemaVersionEntry[];
|
|
40
|
+
};
|
|
41
|
+
export type RegistryManifest = {
|
|
42
|
+
registry_version: string;
|
|
43
|
+
registry_date: string;
|
|
44
|
+
registry_owner: string;
|
|
45
|
+
registry_notes: string;
|
|
46
|
+
events: Record<string, Omit<EventTypeRegistration, "event_type">>;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Location of the registry manifest inside the bundled contracts tree,
|
|
50
|
+
* rooted at `contracts/` exactly as the coordination repo stores it. The
|
|
51
|
+
* `@sguild/dispatcher` package ships this file bundled, so the default
|
|
52
|
+
* needs no sibling coordination checkout. `resolveContractPath` knows the
|
|
53
|
+
* module-relative and cwd-relative layouts this can live at (including the
|
|
54
|
+
* sibling coordination repo) and tries them in order.
|
|
55
|
+
*/
|
|
56
|
+
export declare const DEFAULT_REGISTRY_PATH = "contracts/event-types-registry.json";
|
|
57
|
+
/**
|
|
58
|
+
* Load and cache the registry manifest from the filesystem. Called once per
|
|
59
|
+
* process at startup; subsequent calls return the cached value.
|
|
60
|
+
*
|
|
61
|
+
* Resolution order: an explicit `path` argument (resolved against the
|
|
62
|
+
* process cwd; absolute paths pass through unchanged, which is how the test
|
|
63
|
+
* fixtures load) wins; otherwise the bundled copy shipped inside the
|
|
64
|
+
* package; otherwise the cwd-relative sibling coordination repo. See
|
|
65
|
+
* `resolveContractPath` for the full waterfall.
|
|
66
|
+
*
|
|
67
|
+
* The registry is read from disk synchronously at first use. A future
|
|
68
|
+
* version may switch to an async loader if the registry grows large or
|
|
69
|
+
* moves to a network-fetched location.
|
|
70
|
+
*/
|
|
71
|
+
export declare function loadRegistry(path?: string): RegistryManifest;
|
|
72
|
+
/**
|
|
73
|
+
* Look up an event_type's registration. Returns null if the event_type is
|
|
74
|
+
* not registered; producers calling `dispatcher.publish` against an
|
|
75
|
+
* unregistered event_type will see a clear error rather than a successful
|
|
76
|
+
* emit that consumers cannot decode.
|
|
77
|
+
*/
|
|
78
|
+
export declare function getEventTypeRegistration(event_type: string, manifest?: RegistryManifest): EventTypeRegistration | null;
|
|
79
|
+
/**
|
|
80
|
+
* Look up the active schema_version for an event_type. Returns null if the
|
|
81
|
+
* event_type is not registered or has no active version (every version is
|
|
82
|
+
* deprecated or sunset, which is itself a registry hygiene problem).
|
|
83
|
+
*
|
|
84
|
+
* Producers emitting without an explicit `schema_version` get the active
|
|
85
|
+
* version returned here. During a transition window where multiple versions
|
|
86
|
+
* are simultaneously active, producers MUST supply the version explicitly.
|
|
87
|
+
*/
|
|
88
|
+
export declare function getActiveSchemaVersion(event_type: string, manifest?: RegistryManifest): SchemaVersionEntry | null;
|
|
89
|
+
/**
|
|
90
|
+
* Reset the cached manifest. Test-only; production code does not call this.
|
|
91
|
+
* Exported so test files can reload the registry between fixtures without
|
|
92
|
+
* spinning up a fresh process.
|
|
93
|
+
*/
|
|
94
|
+
export declare function __resetRegistryCacheForTests(): void;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Event-type registry loader and query.
|
|
4
|
+
*
|
|
5
|
+
* Reads the canonical registry manifest at
|
|
6
|
+
* coordination/contracts/event-types-registry.json (path resolved relative to
|
|
7
|
+
* the platform repo's working directory; the registry is checked into the
|
|
8
|
+
* coordination repo and the platform repo references it via its known
|
|
9
|
+
* filesystem location during local development; CI gets a copy via the same
|
|
10
|
+
* mechanism).
|
|
11
|
+
*
|
|
12
|
+
* The registry is the single source of truth for which (event_type,
|
|
13
|
+
* schema_version) pairs are valid. The dispatcher reads it at startup to drive
|
|
14
|
+
* validation; producers register new event_types here before first use per
|
|
15
|
+
* §10.4 of the envelope contract.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.DEFAULT_REGISTRY_PATH = void 0;
|
|
19
|
+
exports.loadRegistry = loadRegistry;
|
|
20
|
+
exports.getEventTypeRegistration = getEventTypeRegistration;
|
|
21
|
+
exports.getActiveSchemaVersion = getActiveSchemaVersion;
|
|
22
|
+
exports.__resetRegistryCacheForTests = __resetRegistryCacheForTests;
|
|
23
|
+
const node_fs_1 = require("node:fs");
|
|
24
|
+
const resolve_contract_path_1 = require("./internal/resolve-contract-path");
|
|
25
|
+
/**
|
|
26
|
+
* Location of the registry manifest inside the bundled contracts tree,
|
|
27
|
+
* rooted at `contracts/` exactly as the coordination repo stores it. The
|
|
28
|
+
* `@sguild/dispatcher` package ships this file bundled, so the default
|
|
29
|
+
* needs no sibling coordination checkout. `resolveContractPath` knows the
|
|
30
|
+
* module-relative and cwd-relative layouts this can live at (including the
|
|
31
|
+
* sibling coordination repo) and tries them in order.
|
|
32
|
+
*/
|
|
33
|
+
exports.DEFAULT_REGISTRY_PATH = "contracts/event-types-registry.json";
|
|
34
|
+
let cachedManifest = null;
|
|
35
|
+
/**
|
|
36
|
+
* Load and cache the registry manifest from the filesystem. Called once per
|
|
37
|
+
* process at startup; subsequent calls return the cached value.
|
|
38
|
+
*
|
|
39
|
+
* Resolution order: an explicit `path` argument (resolved against the
|
|
40
|
+
* process cwd; absolute paths pass through unchanged, which is how the test
|
|
41
|
+
* fixtures load) wins; otherwise the bundled copy shipped inside the
|
|
42
|
+
* package; otherwise the cwd-relative sibling coordination repo. See
|
|
43
|
+
* `resolveContractPath` for the full waterfall.
|
|
44
|
+
*
|
|
45
|
+
* The registry is read from disk synchronously at first use. A future
|
|
46
|
+
* version may switch to an async loader if the registry grows large or
|
|
47
|
+
* moves to a network-fetched location.
|
|
48
|
+
*/
|
|
49
|
+
function loadRegistry(path) {
|
|
50
|
+
if (cachedManifest)
|
|
51
|
+
return cachedManifest;
|
|
52
|
+
const resolvedPath = (0, resolve_contract_path_1.resolveContractPath)(__dirname, exports.DEFAULT_REGISTRY_PATH, {
|
|
53
|
+
priorityPaths: path ? [path] : [],
|
|
54
|
+
});
|
|
55
|
+
const raw = (0, node_fs_1.readFileSync)(resolvedPath, "utf8");
|
|
56
|
+
cachedManifest = JSON.parse(raw);
|
|
57
|
+
return cachedManifest;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Look up an event_type's registration. Returns null if the event_type is
|
|
61
|
+
* not registered; producers calling `dispatcher.publish` against an
|
|
62
|
+
* unregistered event_type will see a clear error rather than a successful
|
|
63
|
+
* emit that consumers cannot decode.
|
|
64
|
+
*/
|
|
65
|
+
function getEventTypeRegistration(event_type, manifest = loadRegistry()) {
|
|
66
|
+
const entry = manifest.events[event_type];
|
|
67
|
+
if (!entry)
|
|
68
|
+
return null;
|
|
69
|
+
return { event_type, ...entry };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Look up the active schema_version for an event_type. Returns null if the
|
|
73
|
+
* event_type is not registered or has no active version (every version is
|
|
74
|
+
* deprecated or sunset, which is itself a registry hygiene problem).
|
|
75
|
+
*
|
|
76
|
+
* Producers emitting without an explicit `schema_version` get the active
|
|
77
|
+
* version returned here. During a transition window where multiple versions
|
|
78
|
+
* are simultaneously active, producers MUST supply the version explicitly.
|
|
79
|
+
*/
|
|
80
|
+
function getActiveSchemaVersion(event_type, manifest = loadRegistry()) {
|
|
81
|
+
const reg = getEventTypeRegistration(event_type, manifest);
|
|
82
|
+
if (!reg)
|
|
83
|
+
return null;
|
|
84
|
+
const active = reg.schema_versions.filter((v) => v.status === "active");
|
|
85
|
+
if (active.length === 0)
|
|
86
|
+
return null;
|
|
87
|
+
if (active.length > 1) {
|
|
88
|
+
throw new Error(`Multiple active schema_versions for event_type '${event_type}'; producers must supply schema_version explicitly during the transition window.`);
|
|
89
|
+
}
|
|
90
|
+
return active[0];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Reset the cached manifest. Test-only; production code does not call this.
|
|
94
|
+
* Exported so test files can reload the registry between fixtures without
|
|
95
|
+
* spinning up a fresh process.
|
|
96
|
+
*/
|
|
97
|
+
function __resetRegistryCacheForTests() {
|
|
98
|
+
cachedManifest = null;
|
|
99
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign/verify primitives shared between the Platform-side fanout worker
|
|
3
|
+
* (producer of signatures) and domain-side inbox handlers (verifiers).
|
|
4
|
+
*
|
|
5
|
+
* Extracted out of fanout.ts so domains can vendor a minimal three-file
|
|
6
|
+
* surface (signature.ts + inbox.ts + types.ts) without pulling in the
|
|
7
|
+
* polling-consumer machinery, registry loader, transport implementation,
|
|
8
|
+
* Prisma client wiring, or any of the rest of the SDK. This is the
|
|
9
|
+
* canonical low-dependency module domains depend on for HMAC.
|
|
10
|
+
*
|
|
11
|
+
* No imports from prisma, the registry, the dispatcher singleton, or
|
|
12
|
+
* any other dispatcher file. Only node:crypto. Keep it that way.
|
|
13
|
+
*
|
|
14
|
+
* Contract: Platform's fanout signs the JSON-serialized envelope body
|
|
15
|
+
* with the per-consumer HMAC secret and sends `X-Sguild-Signature:
|
|
16
|
+
* sha256=<hex>`. The receiving inbox recomputes the same digest from
|
|
17
|
+
* the raw request body and constant-time compares.
|
|
18
|
+
*/
|
|
19
|
+
/** Header carrying the HMAC signature. Sha256 hex digest, prefixed by algorithm. */
|
|
20
|
+
export declare const SIGNATURE_HEADER = "x-sguild-signature";
|
|
21
|
+
/** Header carrying the event_id for log correlation on the consumer side. */
|
|
22
|
+
export declare const EVENT_ID_HEADER = "x-sguild-event-id";
|
|
23
|
+
/** Header carrying the event_type for log correlation. */
|
|
24
|
+
export declare const EVENT_TYPE_HEADER = "x-sguild-event-type";
|
|
25
|
+
/**
|
|
26
|
+
* Compute the HMAC-SHA256 signature for an envelope body. Output is a
|
|
27
|
+
* hex string; the header value is `sha256=<output>`. Body is the exact
|
|
28
|
+
* JSON-serialized envelope the consumer will receive; sender and
|
|
29
|
+
* receiver MUST serialize identically (no whitespace differences,
|
|
30
|
+
* key-ordering differences, etc.) or the signature will mismatch. The
|
|
31
|
+
* recommended pattern: sender produces `JSON.stringify(envelope)` and
|
|
32
|
+
* the receiver verifies against the raw request body before parsing.
|
|
33
|
+
*/
|
|
34
|
+
export declare function signEnvelope(body: string, secret: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Verify a signature against a body. Constant-time comparison to
|
|
37
|
+
* prevent timing attacks. Accepts the signature with or without the
|
|
38
|
+
* `sha256=` prefix; consumers should pass the header verbatim and this
|
|
39
|
+
* function normalizes.
|
|
40
|
+
*
|
|
41
|
+
* Returns true on match, false on mismatch or any parse/length error.
|
|
42
|
+
* Never throws.
|
|
43
|
+
*/
|
|
44
|
+
export declare function verifyEnvelopeSignature(body: string, secret: string, signatureHeaderValue: string): boolean;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Sign/verify primitives shared between the Platform-side fanout worker
|
|
4
|
+
* (producer of signatures) and domain-side inbox handlers (verifiers).
|
|
5
|
+
*
|
|
6
|
+
* Extracted out of fanout.ts so domains can vendor a minimal three-file
|
|
7
|
+
* surface (signature.ts + inbox.ts + types.ts) without pulling in the
|
|
8
|
+
* polling-consumer machinery, registry loader, transport implementation,
|
|
9
|
+
* Prisma client wiring, or any of the rest of the SDK. This is the
|
|
10
|
+
* canonical low-dependency module domains depend on for HMAC.
|
|
11
|
+
*
|
|
12
|
+
* No imports from prisma, the registry, the dispatcher singleton, or
|
|
13
|
+
* any other dispatcher file. Only node:crypto. Keep it that way.
|
|
14
|
+
*
|
|
15
|
+
* Contract: Platform's fanout signs the JSON-serialized envelope body
|
|
16
|
+
* with the per-consumer HMAC secret and sends `X-Sguild-Signature:
|
|
17
|
+
* sha256=<hex>`. The receiving inbox recomputes the same digest from
|
|
18
|
+
* the raw request body and constant-time compares.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.EVENT_TYPE_HEADER = exports.EVENT_ID_HEADER = exports.SIGNATURE_HEADER = void 0;
|
|
22
|
+
exports.signEnvelope = signEnvelope;
|
|
23
|
+
exports.verifyEnvelopeSignature = verifyEnvelopeSignature;
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
/** Header carrying the HMAC signature. Sha256 hex digest, prefixed by algorithm. */
|
|
26
|
+
exports.SIGNATURE_HEADER = "x-sguild-signature";
|
|
27
|
+
/** Header carrying the event_id for log correlation on the consumer side. */
|
|
28
|
+
exports.EVENT_ID_HEADER = "x-sguild-event-id";
|
|
29
|
+
/** Header carrying the event_type for log correlation. */
|
|
30
|
+
exports.EVENT_TYPE_HEADER = "x-sguild-event-type";
|
|
31
|
+
/**
|
|
32
|
+
* Compute the HMAC-SHA256 signature for an envelope body. Output is a
|
|
33
|
+
* hex string; the header value is `sha256=<output>`. Body is the exact
|
|
34
|
+
* JSON-serialized envelope the consumer will receive; sender and
|
|
35
|
+
* receiver MUST serialize identically (no whitespace differences,
|
|
36
|
+
* key-ordering differences, etc.) or the signature will mismatch. The
|
|
37
|
+
* recommended pattern: sender produces `JSON.stringify(envelope)` and
|
|
38
|
+
* the receiver verifies against the raw request body before parsing.
|
|
39
|
+
*/
|
|
40
|
+
function signEnvelope(body, secret) {
|
|
41
|
+
return (0, node_crypto_1.createHmac)("sha256", secret).update(body, "utf8").digest("hex");
|
|
42
|
+
}
|
|
43
|
+
function hexToBytes(hex) {
|
|
44
|
+
if (hex.length % 2 !== 0) {
|
|
45
|
+
throw new Error("invalid hex length");
|
|
46
|
+
}
|
|
47
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
48
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
49
|
+
const byte = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
50
|
+
if (Number.isNaN(byte)) {
|
|
51
|
+
throw new Error("invalid hex byte");
|
|
52
|
+
}
|
|
53
|
+
bytes[i] = byte;
|
|
54
|
+
}
|
|
55
|
+
return bytes;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Verify a signature against a body. Constant-time comparison to
|
|
59
|
+
* prevent timing attacks. Accepts the signature with or without the
|
|
60
|
+
* `sha256=` prefix; consumers should pass the header verbatim and this
|
|
61
|
+
* function normalizes.
|
|
62
|
+
*
|
|
63
|
+
* Returns true on match, false on mismatch or any parse/length error.
|
|
64
|
+
* Never throws.
|
|
65
|
+
*/
|
|
66
|
+
function verifyEnvelopeSignature(body, secret, signatureHeaderValue) {
|
|
67
|
+
const provided = signatureHeaderValue.startsWith("sha256=")
|
|
68
|
+
? signatureHeaderValue.slice("sha256=".length)
|
|
69
|
+
: signatureHeaderValue;
|
|
70
|
+
const expected = signEnvelope(body, secret);
|
|
71
|
+
if (provided.length !== expected.length)
|
|
72
|
+
return false;
|
|
73
|
+
try {
|
|
74
|
+
return (0, node_crypto_1.timingSafeEqual)(hexToBytes(provided), hexToBytes(expected));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|