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