@sincpro/client-framework 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/AGENTS.md +71 -13
  2. package/MANIFESTO.md +59 -14
  3. package/README.md +456 -55
  4. package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
  5. package/dist/circuit_breaker/domain/circuit.js +37 -0
  6. package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
  7. package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
  8. package/dist/circuit_breaker/index.d.ts +9 -0
  9. package/dist/circuit_breaker/index.js +9 -0
  10. package/dist/client/adapters/json-rpc.d.ts +40 -0
  11. package/dist/client/adapters/json-rpc.js +97 -0
  12. package/dist/client/adapters/rest-operations.d.ts +19 -0
  13. package/dist/client/adapters/rest-operations.js +66 -0
  14. package/dist/client/domain/operation.d.ts +73 -0
  15. package/dist/client/domain/operation.js +19 -0
  16. package/dist/client/index.d.ts +5 -1
  17. package/dist/client/index.js +5 -1
  18. package/dist/client/services/guarded.d.ts +17 -0
  19. package/dist/client/services/guarded.js +19 -0
  20. package/dist/client/services/propagation.d.ts +20 -7
  21. package/dist/client/services/propagation.js +44 -15
  22. package/dist/codegen/entrypoint/cli.js +7 -7
  23. package/dist/codegen/services/client.d.ts +7 -8
  24. package/dist/codegen/services/client.js +50 -148
  25. package/dist/codegen/services/scaffold.d.ts +7 -4
  26. package/dist/codegen/services/scaffold.js +23 -27
  27. package/dist/common/failures.d.ts +83 -0
  28. package/dist/common/failures.js +212 -0
  29. package/dist/common/index.d.ts +1 -0
  30. package/dist/common/index.js +1 -0
  31. package/dist/context/domain/execution.d.ts +12 -0
  32. package/dist/context/entrypoint/context.d.ts +5 -4
  33. package/dist/core/domain/bus.d.ts +16 -7
  34. package/dist/core/domain/bus.js +14 -6
  35. package/dist/core/domain/dto.d.ts +33 -3
  36. package/dist/core/domain/dto.js +24 -1
  37. package/dist/core/domain/errors.d.ts +2 -2
  38. package/dist/core/domain/pipeline.d.ts +89 -0
  39. package/dist/core/domain/pipeline.js +1 -0
  40. package/dist/core/domain/services.d.ts +16 -5
  41. package/dist/core/domain/services.js +5 -2
  42. package/dist/core/entrypoint/bounded-context.d.ts +96 -16
  43. package/dist/core/entrypoint/bounded-context.js +244 -39
  44. package/dist/core/index.d.ts +4 -2
  45. package/dist/core/index.js +3 -2
  46. package/dist/core/infrastructure/listeners.d.ts +28 -0
  47. package/dist/core/infrastructure/listeners.js +34 -0
  48. package/dist/core/services/binding.d.ts +2 -2
  49. package/dist/core/services/binding.js +6 -3
  50. package/dist/core/services/execution.d.ts +59 -12
  51. package/dist/core/services/execution.js +114 -19
  52. package/dist/core/services/interceptors.d.ts +15 -0
  53. package/dist/core/services/interceptors.js +25 -0
  54. package/dist/core/services/listening.d.ts +17 -0
  55. package/dist/core/services/listening.js +25 -0
  56. package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
  57. package/dist/event_driven/adapters/memory-event-store.js +23 -0
  58. package/dist/event_driven/adapters/stored-queue.d.ts +68 -0
  59. package/dist/event_driven/adapters/stored-queue.js +193 -0
  60. package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
  61. package/dist/event_driven/adapters/sync-queue.js +19 -0
  62. package/dist/event_driven/domain/envelope.d.ts +28 -0
  63. package/dist/event_driven/domain/envelope.js +19 -0
  64. package/dist/event_driven/domain/queue.d.ts +40 -0
  65. package/dist/event_driven/domain/queue.js +1 -0
  66. package/dist/event_driven/domain/store.d.ts +56 -0
  67. package/dist/event_driven/domain/store.js +1 -0
  68. package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
  69. package/dist/event_driven/entrypoint/publisher.js +35 -0
  70. package/dist/event_driven/entrypoint/subscriber.d.ts +42 -0
  71. package/dist/event_driven/entrypoint/subscriber.js +62 -0
  72. package/dist/event_driven/entrypoint/subscriptions.d.ts +110 -0
  73. package/dist/event_driven/entrypoint/subscriptions.js +184 -0
  74. package/dist/event_driven/index.d.ts +22 -0
  75. package/dist/event_driven/index.js +20 -0
  76. package/dist/index.d.ts +7 -2
  77. package/dist/index.js +7 -2
  78. package/dist/observability/adapters/memory.d.ts +1 -1
  79. package/dist/observability/domain/logging.d.ts +10 -4
  80. package/dist/observability/services/logging.d.ts +7 -4
  81. package/dist/observability/services/logging.js +3 -2
  82. package/dist/observability/services/outcome.d.ts +6 -6
  83. package/dist/observability/services/outcome.js +10 -13
  84. package/package.json +1 -1
@@ -0,0 +1,193 @@
1
+ import { failureOf } from "../../common/failures.js";
2
+ import { readValues, rebuilt, valuesOf, } from "../../common/serialization.js";
3
+ import { loggerAdapter } from "../../observability/services/logging.js";
4
+ function rebuildEvent(type, payload) {
5
+ const values = readValues(payload);
6
+ if (typeof type.fromJSON === "function")
7
+ return rebuilt(values, type);
8
+ return Object.assign(Object.create(type.prototype), values);
9
+ }
10
+ function described(boundedContext, error) {
11
+ const failure = failureOf(error);
12
+ return {
13
+ boundedContext,
14
+ kind: failure.kind,
15
+ reason: failure.reason,
16
+ message: error instanceof Error ? error.message : String(error),
17
+ };
18
+ }
19
+ /**
20
+ * The queue that stores an event before delivering it, and delivers it until every bounded
21
+ * context that hears it has it — the backend's store-and-forward (`Forwarding`), over a store
22
+ * each platform plugs.
23
+ *
24
+ * const queue = storedQueue({ store: sqliteEventStore(db), subscriber, ready: online });
25
+ * shopBoundedContext.addDependency("publisher", new Publisher(queue));
26
+ * connectivity.onBack(() => queue.drain());
27
+ *
28
+ * Context: what a phone did by hand in its event bus, the same on every platform. `publish`
29
+ * returns once the event is stored — nobody waits for the delivery. Then, one at a time and in
30
+ * the order stored:
31
+ *
32
+ * 1. An event not ready (`ready`) waits while the rest go on.
33
+ * 2. Nobody hears its name: delivered — a fact recorded, nobody's to act on.
34
+ * 3. Rebuilt as its class; one that cannot be is set aside.
35
+ * 4. Executed in every bounded context that hears it and does not have it yet (`reached`).
36
+ * 5. A failure every attempt would repeat (`PERMANENT_KINDS`: a refusal, invalid, not found) sets
37
+ * that context aside; any other is tried again after the backoff — or what the failure asks
38
+ * for (`retryAfter`, an open circuit's time left) —, and what comes after waits, so the
39
+ * order holds; after `maxAttempts`, it is set aside too.
40
+ * Final: delivered when every context has it, set aside — the dead-letter queue — when one
41
+ * refused it for good or ran out of attempts; `onSettled` hears either.
42
+ *
43
+ * Delivery is at least once: a process that stops after a context executed an event and before
44
+ * the store saved it executes it there again on the next drain. What must not act twice keys what
45
+ * it does by the envelope's id — `this.execution.causationId` in a use case of the event, or in an
46
+ * `EventSubscriber`, which hands it to the Command it executes — or by the entity the event names.
47
+ * And one process drains a store: two tabs over one IndexedDB claim it first (the Web Locks API).
48
+ */
49
+ export function storedQueue(options) {
50
+ const { store, subscriber } = options;
51
+ const firstWait = options.wait ?? 1000;
52
+ const longestWait = options.longestWait ?? 60_000;
53
+ const maxAttempts = options.maxAttempts ?? 10;
54
+ if (!(maxAttempts >= 1))
55
+ throw new TypeError("A stored queue needs one attempt at least");
56
+ const rebuild = options.rebuild ?? rebuildEvent;
57
+ let draining;
58
+ let again = false;
59
+ let timer;
60
+ const settle = async (stored) => {
61
+ await store.save(stored);
62
+ try {
63
+ options.onSettled?.(stored);
64
+ }
65
+ catch {
66
+ // Telling a screen never changes a delivery.
67
+ }
68
+ };
69
+ const wakeAt = (at) => {
70
+ if (timer !== undefined)
71
+ clearTimeout(timer);
72
+ timer = setTimeout(() => {
73
+ timer = undefined;
74
+ void queue.drain();
75
+ }, Math.max(0, at - Date.now()));
76
+ // A timer of its own must not keep a process alive that has nothing else to do.
77
+ timer.unref?.();
78
+ };
79
+ /** One pass over what waits; it stops at an event that must wait its turn. */
80
+ const pass = async () => {
81
+ const skip = new Set();
82
+ for (;;) {
83
+ const head = await store.next(skip);
84
+ if (head === undefined)
85
+ return;
86
+ const id = head.envelope.id;
87
+ if (head.retryAt !== null && Date.parse(head.retryAt) > Date.now()) {
88
+ wakeAt(Date.parse(head.retryAt));
89
+ return;
90
+ }
91
+ if (options.ready && !(await options.ready(head))) {
92
+ skip.add(id);
93
+ continue;
94
+ }
95
+ const attempts = head.attempts + 1;
96
+ const type = subscriber.eventTypeOf(head.envelope.name);
97
+ if (type === undefined) {
98
+ await settle({ ...head, state: "delivered", attempts, retryAt: null, failures: [] });
99
+ continue;
100
+ }
101
+ let event;
102
+ try {
103
+ event = rebuild(type, head.payload);
104
+ }
105
+ catch (error) {
106
+ await settle({
107
+ ...head,
108
+ state: "set_aside",
109
+ attempts,
110
+ retryAt: null,
111
+ failures: [described("", error)],
112
+ });
113
+ continue;
114
+ }
115
+ const handled = await subscriber.handle(event, head.envelope, head.reached);
116
+ const reached = [...head.reached];
117
+ const failures = [];
118
+ let retryAfter = 0;
119
+ let repeat = false;
120
+ for (const one of handled) {
121
+ if (!("error" in one)) {
122
+ reached.push(one.boundedContext);
123
+ continue;
124
+ }
125
+ failures.push(described(one.boundedContext, one.error));
126
+ const failure = failureOf(one.error);
127
+ if (failure.permanent)
128
+ reached.push(one.boundedContext);
129
+ else {
130
+ repeat = true;
131
+ retryAfter = Math.max(retryAfter, (failure.retryAfter ?? 0) * 1000);
132
+ }
133
+ }
134
+ if (repeat && attempts < maxAttempts) {
135
+ const backoff = Math.min(longestWait, firstWait * 2 ** (attempts - 1));
136
+ const at = Date.now() + Math.max(backoff, retryAfter);
137
+ await settle({
138
+ ...head,
139
+ state: "pending",
140
+ attempts,
141
+ retryAt: new Date(at).toISOString(),
142
+ reached,
143
+ failures,
144
+ });
145
+ wakeAt(at);
146
+ return;
147
+ }
148
+ await settle({
149
+ ...head,
150
+ state: failures.length === 0 ? "delivered" : "set_aside",
151
+ attempts,
152
+ retryAt: null,
153
+ reached,
154
+ failures,
155
+ });
156
+ }
157
+ };
158
+ const queue = {
159
+ async put(event, envelope) {
160
+ await store.add({
161
+ envelope,
162
+ payload: valuesOf(event),
163
+ state: "pending",
164
+ attempts: 0,
165
+ retryAt: null,
166
+ reached: [],
167
+ failures: [],
168
+ });
169
+ queue.drain().catch((error) => loggerAdapter.error("The stored queue could not deliver", {
170
+ reason: failureOf(error).reason,
171
+ }));
172
+ },
173
+ drain() {
174
+ if (draining !== undefined) {
175
+ again = true;
176
+ return draining;
177
+ }
178
+ draining = (async () => {
179
+ try {
180
+ do {
181
+ again = false;
182
+ await pass();
183
+ } while (again);
184
+ }
185
+ finally {
186
+ draining = undefined;
187
+ }
188
+ })();
189
+ return draining;
190
+ },
191
+ };
192
+ return queue;
193
+ }
@@ -0,0 +1,11 @@
1
+ import type { EventQueue, Subscribes } from "../domain/queue.js";
2
+ /**
3
+ * The queue that delivers in the call, in memory — the backend's `SyncQueue`: `publish` returns
4
+ * once every bounded context that hears the event executed it. What a web application starts
5
+ * with, and what a test proves a flow with.
6
+ *
7
+ * Context: nothing is stored, so nothing outlives the process — a phone's queue is durable and
8
+ * the platform's. Every subscriber runs; then the first failure, if any, reaches the publisher,
9
+ * as an in-process call would.
10
+ */
11
+ export declare function syncQueue(subscriber: Subscribes): EventQueue;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The queue that delivers in the call, in memory — the backend's `SyncQueue`: `publish` returns
3
+ * once every bounded context that hears the event executed it. What a web application starts
4
+ * with, and what a test proves a flow with.
5
+ *
6
+ * Context: nothing is stored, so nothing outlives the process — a phone's queue is durable and
7
+ * the platform's. Every subscriber runs; then the first failure, if any, reaches the publisher,
8
+ * as an in-process call would.
9
+ */
10
+ export function syncQueue(subscriber) {
11
+ return {
12
+ async put(event, envelope) {
13
+ const handled = await subscriber.handle(event, envelope);
14
+ const failed = handled.find((one) => "error" in one);
15
+ if (failed && "error" in failed)
16
+ throw failed.error;
17
+ },
18
+ };
19
+ }
@@ -0,0 +1,28 @@
1
+ import type { DomainEvent } from "../../core/domain/dto.js";
2
+ import type { Cause } from "../../core/domain/pipeline.js";
3
+ /**
4
+ * What travels beside a published event: who it is, which flow it belongs to and what caused it
5
+ * — the backend's `Envelope` and the chain of its `DomainEvent` (`correlation_id`,
6
+ * `causation_id`), kept beside the event rather than in it, so an event class carries only what
7
+ * happened.
8
+ *
9
+ * { name: "shop.order.v1.paid", id: "0193…", correlationId: "0193…", causationId: "0192…",
10
+ * publishedAt: "2026-11-10T23:31:53.000Z" }
11
+ *
12
+ * Context: a queue stores it with the event and hands it back on delivery
13
+ * (`context.execute(event, envelope)`): the run that executes the event continues the flow —
14
+ * the same `correlationId`, caused by this event's `id` —, three services and two devices later.
15
+ */
16
+ export type Envelope = Cause & {
17
+ /** The event's wire name (`eventName`). */
18
+ readonly name: string;
19
+ /** What directly caused it: the run that published it; `null` for the head of a flow. */
20
+ readonly causationId: string | null;
21
+ /** When it was published, ISO. */
22
+ readonly publishedAt: string;
23
+ };
24
+ /**
25
+ * The envelope of `event`, published by the run `cause` names — or the head of its own flow when
26
+ * nothing caused it (its `correlationId` is its own `id`).
27
+ */
28
+ export declare function envelopeOf(event: DomainEvent, cause?: Cause): Envelope;
@@ -0,0 +1,19 @@
1
+ import { newEntityId } from "../../common/ids.js";
2
+ /**
3
+ * The envelope of `event`, published by the run `cause` names — or the head of its own flow when
4
+ * nothing caused it (its `correlationId` is its own `id`).
5
+ */
6
+ export function envelopeOf(event, cause) {
7
+ const name = event.name;
8
+ if (name.trim() === "") {
9
+ throw new Error(`${event.constructor.name} has no eventName: an event is published by its wire name`);
10
+ }
11
+ const id = newEntityId();
12
+ return {
13
+ name,
14
+ id,
15
+ correlationId: cause?.correlationId ?? id,
16
+ causationId: cause?.id ?? null,
17
+ publishedAt: new Date().toISOString(),
18
+ };
19
+ }
@@ -0,0 +1,40 @@
1
+ import type { DomainEvent, EventClass } from "../../core/domain/dto.js";
2
+ import type { Envelope } from "./envelope.js";
3
+ /**
4
+ * What carries a published event to whoever hears it — the port each platform implements with
5
+ * its own technology, as the backend's `Queue`:
6
+ *
7
+ * - `syncQueue(subscriber)` — this package's: in the call, in memory (the web's default, tests).
8
+ * - a phone: SQLite, so an event outlives a restart, with retries, a dead-letter queue and the
9
+ * connectivity it waits for.
10
+ * - a browser: IndexedDB to work offline, `BroadcastChannel` to tell the other tabs.
11
+ * - a server's broker, through the backend.
12
+ *
13
+ * Context: delivery is the platform's — when, how often, how durably —; what it delivers and to
14
+ * whom is the same everywhere: an event, its envelope, and a `Subscribes` that executes it in
15
+ * every bounded context that hears it.
16
+ */
17
+ export interface EventQueue {
18
+ put(event: DomainEvent, envelope: Envelope): void | Promise<void>;
19
+ }
20
+ /** What one bounded context answered, or failed with, for a delivered event. */
21
+ export type Handled = {
22
+ readonly boundedContext: string;
23
+ readonly answer: unknown;
24
+ } | {
25
+ readonly boundedContext: string;
26
+ readonly error: unknown;
27
+ };
28
+ /**
29
+ * What a queue hands an event to — the backend's `Subscribes`: which class a stored name is
30
+ * rebuilt as, and the event executed by every subscriber.
31
+ */
32
+ export interface Subscribes {
33
+ /** The class an event of that wire name is rebuilt as, or `undefined` when nobody hears it. */
34
+ eventTypeOf(name: string): EventClass<DomainEvent> | undefined;
35
+ /**
36
+ * The event executed in every bounded context that hears it — but those `skip` names, which
37
+ * already have it — and what each answered.
38
+ */
39
+ handle(event: DomainEvent, envelope?: Envelope, skip?: readonly string[]): Promise<Handled[]>;
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
1
+ import type { JsonValue } from "../../common/serialization.js";
2
+ import type { Envelope } from "./envelope.js";
3
+ import type { FailureKind } from "@sincpro/criteria";
4
+ /**
5
+ * Where a stored queue keeps what it was handed until every bounded context that hears it has
6
+ * executed it — the port each platform implements with its own technology:
7
+ *
8
+ * - `memoryEventStore()` — this package's: the process's memory (a browser tab, a test).
9
+ * - a phone: a SQLite table, so an event outlives a restart; the set-aside ones are its
10
+ * dead-letter queue.
11
+ * - a browser: IndexedDB, to work offline.
12
+ *
13
+ * Context: storing is the platform's, deciding what to do with what is stored is not — when to
14
+ * try again, what to set aside, which contexts already have it (`storedQueue`). So the port only
15
+ * keeps records, in the order they came, and finds the next one waiting.
16
+ */
17
+ export interface EventStore {
18
+ /** Keeps a published event, waiting. */
19
+ add(stored: StoredEvent): void | Promise<void>;
20
+ /**
21
+ * The oldest event still waiting (`pending`), in the order added — but those `skip` names by
22
+ * envelope id: not ready yet (no connection, no session), they wait while the rest go on.
23
+ */
24
+ next(skip: ReadonlySet<string>): StoredEvent | undefined | Promise<StoredEvent | undefined>;
25
+ /** What changed of a stored event — found by its envelope's id. */
26
+ save(stored: StoredEvent): void | Promise<void>;
27
+ }
28
+ /**
29
+ * - `pending`: waiting to be delivered — again, after `retryAt`, when it failed in a way a retry
30
+ * may fix.
31
+ * - `delivered`: every bounded context that hears it executed it.
32
+ * - `set_aside`: a context failed in a way every attempt would repeat (`PERMANENT_KINDS`) — the
33
+ * dead-letter queue; it never holds back what comes after.
34
+ */
35
+ export type StoredState = "pending" | "delivered" | "set_aside";
36
+ /** Why a bounded context did not execute it. */
37
+ export type DeliveryFailure = {
38
+ readonly boundedContext: string;
39
+ readonly kind: FailureKind;
40
+ readonly reason: string;
41
+ readonly message: string;
42
+ };
43
+ /** A published event as a store keeps it: its envelope, its values and how its delivery goes. */
44
+ export type StoredEvent = {
45
+ readonly envelope: Envelope;
46
+ /** The event's values (`valuesOf`): rebuilt as its class on delivery. */
47
+ readonly payload: JsonValue;
48
+ readonly state: StoredState;
49
+ readonly attempts: number;
50
+ /** When it may be tried again, ISO; `null` when it need not wait. */
51
+ readonly retryAt: string | null;
52
+ /** The bounded contexts done with it — executed, or set aside —: a retry never runs them again. */
53
+ readonly reached: readonly string[];
54
+ /** Why the last attempt did not reach every context. */
55
+ readonly failures: readonly DeliveryFailure[];
56
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { BIND, type Execution } from "../../context/domain/execution.js";
2
+ import type { DomainEvent } from "../../core/domain/dto.js";
3
+ import type { Cause } from "../../core/domain/pipeline.js";
4
+ import { type Envelope } from "../domain/envelope.js";
5
+ import type { EventQueue } from "../domain/queue.js";
6
+ /**
7
+ * Puts events into a queue — what a Feature holds to tell whoever listens, as the backend's
8
+ * `Publisher`:
9
+ *
10
+ * shopBoundedContext.addDependency("publisher", new Publisher(queue));
11
+ * // in a Feature:
12
+ * await this.publisher.publish(new OrderPaid(order.id)); // its Envelope
13
+ *
14
+ * Context: a dependency of a bounded context is bound to each run (`BIND`), so what a Feature
15
+ * publishes joins that run's flow — the same `correlationId`, caused by the run — with nothing
16
+ * passed by hand. Outside a run, an event is the head of its own flow.
17
+ */
18
+ export declare class Publisher {
19
+ private readonly queue;
20
+ private readonly cause?;
21
+ constructor(queue: EventQueue, cause?: Cause | undefined);
22
+ /** `event` into the queue, with its envelope; answers the envelope once the queue took it. */
23
+ publish(event: DomainEvent): Promise<Envelope>;
24
+ /** The same publisher, publishing as `execution`: its events join that run's flow. */
25
+ [BIND](execution: Execution): Publisher;
26
+ }
@@ -0,0 +1,35 @@
1
+ import { BIND } from "../../context/domain/execution.js";
2
+ import { envelopeOf, } from "../domain/envelope.js";
3
+ /**
4
+ * Puts events into a queue — what a Feature holds to tell whoever listens, as the backend's
5
+ * `Publisher`:
6
+ *
7
+ * shopBoundedContext.addDependency("publisher", new Publisher(queue));
8
+ * // in a Feature:
9
+ * await this.publisher.publish(new OrderPaid(order.id)); // its Envelope
10
+ *
11
+ * Context: a dependency of a bounded context is bound to each run (`BIND`), so what a Feature
12
+ * publishes joins that run's flow — the same `correlationId`, caused by the run — with nothing
13
+ * passed by hand. Outside a run, an event is the head of its own flow.
14
+ */
15
+ export class Publisher {
16
+ queue;
17
+ cause;
18
+ constructor(queue, cause) {
19
+ this.queue = queue;
20
+ this.cause = cause;
21
+ }
22
+ /** `event` into the queue, with its envelope; answers the envelope once the queue took it. */
23
+ async publish(event) {
24
+ const envelope = envelopeOf(event, this.cause);
25
+ await this.queue.put(event, envelope);
26
+ return envelope;
27
+ }
28
+ /** The same publisher, publishing as `execution`: its events join that run's flow. */
29
+ [BIND](execution) {
30
+ return new Publisher(this.queue, {
31
+ id: execution.id,
32
+ correlationId: execution.correlationId,
33
+ });
34
+ }
35
+ }
@@ -0,0 +1,42 @@
1
+ import type { DomainEvent, EventClass } from "../../core/domain/dto.js";
2
+ import type { Cause } from "../../core/domain/pipeline.js";
3
+ import type { Envelope } from "../domain/envelope.js";
4
+ import type { Handled, Subscribes } from "../domain/queue.js";
5
+ /**
6
+ * What the subscriber needs of whoever hears events — a bounded context (a use case of the event)
7
+ * or `Subscriptions` (an `EventSubscriber` of it): its name, whether it hears an event, and
8
+ * running it.
9
+ */
10
+ export type HearingContext = {
11
+ readonly name: string;
12
+ handlerOf(dto: abstract new (...args: never[]) => DomainEvent): unknown;
13
+ eventTypeOf(name: string): EventClass<DomainEvent> | undefined;
14
+ execute(event: DomainEvent, cause?: Cause): Promise<unknown>;
15
+ };
16
+ /**
17
+ * The door of a queue: hands an event to everyone who hears it — the backend's
18
+ * `Subscriber(buses)`:
19
+ *
20
+ * const subscriber = new Subscriber(shopBoundedContext, mailBoundedContext, ticketsSubscriptions);
21
+ * const queue = syncQueue(subscriber); // or a platform's durable queue
22
+ *
23
+ * Context: who hears an event is a bounded context with a use case of it (a Feature or an
24
+ * ApplicationService registered for its class) or `Subscriptions` with an `EventSubscriber` of
25
+ * it — the entrypoint that calls use cases. Several hearing one event is several executions, one
26
+ * each, in the order named; one that fails does not stop the others: each answer, or failure, is
27
+ * said under its name — which a stored queue keeps, so a redelivery never runs again one that
28
+ * already heard it. Two of them never share a name.
29
+ */
30
+ export declare class Subscriber implements Subscribes {
31
+ private readonly contexts;
32
+ constructor(...contexts: HearingContext[]);
33
+ eventTypeOf(name: string): EventClass<DomainEvent> | undefined;
34
+ /**
35
+ * 1. Every bounded context registered for the event's class, in the order named — but those
36
+ * `skip` names, which already have it.
37
+ * 2. Each executes it — continuing the envelope's flow when there is one —, and its answer or
38
+ * failure is kept; a failure does not stop the next.
39
+ * Final: what each answered, or failed with.
40
+ */
41
+ handle(event: DomainEvent, envelope?: Envelope, skip?: readonly string[]): Promise<Handled[]>;
42
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The door of a queue: hands an event to everyone who hears it — the backend's
3
+ * `Subscriber(buses)`:
4
+ *
5
+ * const subscriber = new Subscriber(shopBoundedContext, mailBoundedContext, ticketsSubscriptions);
6
+ * const queue = syncQueue(subscriber); // or a platform's durable queue
7
+ *
8
+ * Context: who hears an event is a bounded context with a use case of it (a Feature or an
9
+ * ApplicationService registered for its class) or `Subscriptions` with an `EventSubscriber` of
10
+ * it — the entrypoint that calls use cases. Several hearing one event is several executions, one
11
+ * each, in the order named; one that fails does not stop the others: each answer, or failure, is
12
+ * said under its name — which a stored queue keeps, so a redelivery never runs again one that
13
+ * already heard it. Two of them never share a name.
14
+ */
15
+ export class Subscriber {
16
+ contexts;
17
+ constructor(...contexts) {
18
+ const names = new Set();
19
+ for (const context of contexts) {
20
+ if (names.has(context.name)) {
21
+ throw new Error(`Two of those who hear events are named ${context.name}: the queue keeps who heard an event by its name`);
22
+ }
23
+ names.add(context.name);
24
+ }
25
+ this.contexts = contexts;
26
+ }
27
+ eventTypeOf(name) {
28
+ for (const context of this.contexts) {
29
+ const found = context.eventTypeOf(name);
30
+ if (found)
31
+ return found;
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * 1. Every bounded context registered for the event's class, in the order named — but those
37
+ * `skip` names, which already have it.
38
+ * 2. Each executes it — continuing the envelope's flow when there is one —, and its answer or
39
+ * failure is kept; a failure does not stop the next.
40
+ * Final: what each answered, or failed with.
41
+ */
42
+ async handle(event, envelope, skip = []) {
43
+ const type = Object.getPrototypeOf(event)?.constructor;
44
+ const handled = [];
45
+ for (const context of this.contexts) {
46
+ if (type === undefined || context.handlerOf(type) === undefined)
47
+ continue;
48
+ if (skip.includes(context.name))
49
+ continue;
50
+ try {
51
+ handled.push({
52
+ boundedContext: context.name,
53
+ answer: await context.execute(event, envelope),
54
+ });
55
+ }
56
+ catch (error) {
57
+ handled.push({ boundedContext: context.name, error });
58
+ }
59
+ }
60
+ return handled;
61
+ }
62
+ }
@@ -0,0 +1,110 @@
1
+ import type { Execution } from "../../context/domain/execution.js";
2
+ import type { ContextValues } from "../../context/domain/store.js";
3
+ import { DomainEvent, type EventClass } from "../../core/domain/dto.js";
4
+ import type { Cause, ExecuteOptions } from "../../core/domain/pipeline.js";
5
+ import type { LayerLogger } from "../../observability/domain/logging.js";
6
+ import type { Span } from "../../observability/domain/tracing.js";
7
+ /**
8
+ * An entrypoint that hears one domain event and calls the use cases it starts — as a cron calls
9
+ * them on a tick, or a screen on a gesture:
10
+ *
11
+ * class LoggedSuccessfully extends EventSubscriber<OdooLoggedIn> {
12
+ * static readonly subscriberName = "LoggedSuccessfully";
13
+ * declare readonly ticketsBoundedContext: BoundedContext<TicketsDependencies>;
14
+ *
15
+ * async handle(event: OdooLoggedIn) {
16
+ * await this.ticketsBoundedContext.execute(new CommandPrepareSession(event.uid));
17
+ * }
18
+ * }
19
+ *
20
+ * Context: a subscriber is not a use case. It reads the event, builds the Command — or the
21
+ * Commands — and executes them on a bounded context it was given; what to decide, and work of
22
+ * several steps, is a Feature or an ApplicationService it executes. While `handle` runs,
23
+ * `this.span`, `this.logger`, `this.signal`, `this.execution` and every dependency are that
24
+ * delivery's: a bounded context it was given executes as a child of it — the same flow, caused by
25
+ * it (`BIND`). `subscriberName` is stable metadata, never `constructor.name`. Like a service, a
26
+ * subscriber is one instance for every delivery: what belongs to one goes in its locals.
27
+ */
28
+ export declare abstract class EventSubscriber<E extends DomainEvent, Context extends object = ContextValues> {
29
+ /** What this delivery runs with: the process's context. */
30
+ readonly context: Readonly<Partial<Context>>;
31
+ /** This delivery's span: the Commands it executes are its children. */
32
+ readonly span: Span;
33
+ /** This delivery's logger: every line carries its flow and trace. */
34
+ readonly logger: LayerLogger;
35
+ /** What cancels this delivery, when it was executed with one. */
36
+ readonly signal: AbortSignal | undefined;
37
+ /**
38
+ * This delivery: its id, flow (`correlationId`) and cause — the envelope's id, the key that
39
+ * makes hearing an event twice harmless.
40
+ */
41
+ readonly execution: Execution;
42
+ abstract handle(event: E): unknown;
43
+ }
44
+ type SubscriberClass<E extends DomainEvent> = {
45
+ new (): EventSubscriber<E>;
46
+ readonly subscriberName: string;
47
+ };
48
+ /** What a subscription takes the place of: the subscriber registered now for the event. */
49
+ export type SubscriptionOptions = {
50
+ replaces?: abstract new (...args: never[]) => object;
51
+ };
52
+ /** What `subscribe(...)` answers: the function that registers the subscriber. */
53
+ export type Subscribing<E extends DomainEvent> = <S extends SubscriberClass<NoInfer<E>>>(Subscriber: S, decorator?: ClassDecoratorContext<S>) => S;
54
+ /**
55
+ * The subscribers of one part of an application — which `EventSubscriber` hears which event —,
56
+ * with the dependencies they call: what a queue's `Subscriber` hands an event to, beside the
57
+ * bounded contexts:
58
+ *
59
+ * export const ticketsSubscriptions = new Subscriptions<TicketsEntrypoints>("tickets-subscriptions");
60
+ * ticketsSubscriptions.addDependency("ticketsBoundedContext", ticketsBoundedContext);
61
+ * ticketsSubscriptions.subscribe(OdooLoggedIn)(LoggedSuccessfully);
62
+ *
63
+ * storedQueue({ store, subscriber: new Subscriber(odooBoundedContext, ticketsSubscriptions) });
64
+ *
65
+ * Context: the shape of a bounded context, and of the backend's `Crons`: a name that is its
66
+ * identity in spans, logs and the queue's record of who already heard an event — so it differs
67
+ * from every bounded context's —, dependencies by name, and a function (or a decorator) that
68
+ * registers. One subscriber per event here: several reactions to one event are one
69
+ * ApplicationService it executes, or subscribers of other registries. Each delivery is a run of
70
+ * its own (`<name>/<subscriberName>`, layer `entrypoint`), continuing the envelope's flow; it is
71
+ * not a use case, so interceptors and listeners do not see it — they see the use cases it calls.
72
+ */
73
+ export declare class Subscriptions<D extends object = Record<never, never>> {
74
+ readonly name: string;
75
+ private readonly registrations;
76
+ private readonly dependencyValues;
77
+ constructor(name: string);
78
+ /** A dependency its subscribers find on `this`, bound to each delivery; `replaces` as a bounded context's. */
79
+ addDependency<K extends keyof D & string>(name: K, dependency: D[K], options?: {
80
+ replaces: D[K];
81
+ }): this;
82
+ /**
83
+ * Registers the subscriber that hears `event` here. With `replaces`, it takes the place of the
84
+ * one registered now.
85
+ */
86
+ subscribe<E extends DomainEvent>(event: EventClass<E>, options?: SubscriptionOptions): Subscribing<E>;
87
+ /** The subscriber registered now for an event class — what a `replaces` names. */
88
+ handlerOf(event: abstract new (...args: never[]) => DomainEvent): (abstract new (...args: never[]) => object) | undefined;
89
+ /** The event class registered here under a wire name, or `undefined` when nobody here hears it. */
90
+ eventTypeOf(name: string): EventClass<DomainEvent> | undefined;
91
+ /**
92
+ * `event` heard by its subscriber here — continuing the envelope's flow when it is given
93
+ * (`execute(event, envelope)`) — and what `handle` returned.
94
+ *
95
+ * 1. The subscriber registered for the event's exact class; none: an error.
96
+ * 2. A run `<name>/<subscriberName>`, layer `entrypoint`: its span, log lines and — when it
97
+ * fails with a fault nothing reported yet — its report; its dependencies bound to it.
98
+ * Final: what `handle` returned; its failure, as it is, to the queue that delivered it.
99
+ */
100
+ execute(event: DomainEvent, options?: ExecuteOptions | Cause): Promise<unknown>;
101
+ /**
102
+ * 1. The subscriber is an `EventSubscriber`, for a domain event class with its `eventName`.
103
+ * 2. The event has no subscriber here yet — or, with `replaces`, the one it has is that one.
104
+ * 3. Its `subscriberName` is given and no other subscriber here has it; no other event class
105
+ * here has the event's name.
106
+ * Final: registered, said on its spans when it replaced another; the next delivery hears it.
107
+ */
108
+ private register;
109
+ }
110
+ export {};