@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.
- package/AGENTS.md +71 -13
- package/MANIFESTO.md +59 -14
- package/README.md +456 -55
- package/dist/circuit_breaker/domain/circuit.d.ts +29 -0
- package/dist/circuit_breaker/domain/circuit.js +37 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.d.ts +56 -0
- package/dist/circuit_breaker/entrypoint/circuit-breaker.js +94 -0
- package/dist/circuit_breaker/index.d.ts +9 -0
- package/dist/circuit_breaker/index.js +9 -0
- package/dist/client/adapters/json-rpc.d.ts +40 -0
- package/dist/client/adapters/json-rpc.js +97 -0
- package/dist/client/adapters/rest-operations.d.ts +19 -0
- package/dist/client/adapters/rest-operations.js +66 -0
- package/dist/client/domain/operation.d.ts +73 -0
- package/dist/client/domain/operation.js +19 -0
- package/dist/client/index.d.ts +5 -1
- package/dist/client/index.js +5 -1
- package/dist/client/services/guarded.d.ts +17 -0
- package/dist/client/services/guarded.js +19 -0
- package/dist/client/services/propagation.d.ts +20 -7
- package/dist/client/services/propagation.js +44 -15
- package/dist/codegen/entrypoint/cli.js +7 -7
- package/dist/codegen/services/client.d.ts +7 -8
- package/dist/codegen/services/client.js +50 -148
- package/dist/codegen/services/scaffold.d.ts +7 -4
- package/dist/codegen/services/scaffold.js +23 -27
- package/dist/common/failures.d.ts +83 -0
- package/dist/common/failures.js +212 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +1 -0
- package/dist/context/domain/execution.d.ts +12 -0
- package/dist/context/entrypoint/context.d.ts +5 -4
- package/dist/core/domain/bus.d.ts +16 -7
- package/dist/core/domain/bus.js +14 -6
- package/dist/core/domain/dto.d.ts +33 -3
- package/dist/core/domain/dto.js +24 -1
- package/dist/core/domain/errors.d.ts +2 -2
- package/dist/core/domain/pipeline.d.ts +89 -0
- package/dist/core/domain/pipeline.js +1 -0
- package/dist/core/domain/services.d.ts +16 -5
- package/dist/core/domain/services.js +5 -2
- package/dist/core/entrypoint/bounded-context.d.ts +96 -16
- package/dist/core/entrypoint/bounded-context.js +244 -39
- package/dist/core/index.d.ts +4 -2
- package/dist/core/index.js +3 -2
- package/dist/core/infrastructure/listeners.d.ts +28 -0
- package/dist/core/infrastructure/listeners.js +34 -0
- package/dist/core/services/binding.d.ts +2 -2
- package/dist/core/services/binding.js +6 -3
- package/dist/core/services/execution.d.ts +59 -12
- package/dist/core/services/execution.js +114 -19
- package/dist/core/services/interceptors.d.ts +15 -0
- package/dist/core/services/interceptors.js +25 -0
- package/dist/core/services/listening.d.ts +17 -0
- package/dist/core/services/listening.js +25 -0
- package/dist/event_driven/adapters/memory-event-store.d.ts +10 -0
- package/dist/event_driven/adapters/memory-event-store.js +23 -0
- package/dist/event_driven/adapters/stored-queue.d.ts +68 -0
- package/dist/event_driven/adapters/stored-queue.js +193 -0
- package/dist/event_driven/adapters/sync-queue.d.ts +11 -0
- package/dist/event_driven/adapters/sync-queue.js +19 -0
- package/dist/event_driven/domain/envelope.d.ts +28 -0
- package/dist/event_driven/domain/envelope.js +19 -0
- package/dist/event_driven/domain/queue.d.ts +40 -0
- package/dist/event_driven/domain/queue.js +1 -0
- package/dist/event_driven/domain/store.d.ts +56 -0
- package/dist/event_driven/domain/store.js +1 -0
- package/dist/event_driven/entrypoint/publisher.d.ts +26 -0
- package/dist/event_driven/entrypoint/publisher.js +35 -0
- package/dist/event_driven/entrypoint/subscriber.d.ts +42 -0
- package/dist/event_driven/entrypoint/subscriber.js +62 -0
- package/dist/event_driven/entrypoint/subscriptions.d.ts +110 -0
- package/dist/event_driven/entrypoint/subscriptions.js +184 -0
- package/dist/event_driven/index.d.ts +22 -0
- package/dist/event_driven/index.js +20 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/observability/adapters/memory.d.ts +1 -1
- package/dist/observability/domain/logging.d.ts +10 -4
- package/dist/observability/services/logging.d.ts +7 -4
- package/dist/observability/services/logging.js +3 -2
- package/dist/observability/services/outcome.d.ts +6 -6
- package/dist/observability/services/outcome.js +10 -13
- 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 {};
|