@sincpro/client-framework 1.0.0 → 1.1.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 +53 -12
- package/MANIFESTO.md +56 -13
- package/README.md +329 -33
- 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 +6 -3
- package/dist/codegen/services/scaffold.js +14 -13
- 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/core/domain/bus.d.ts +16 -7
- package/dist/core/domain/bus.js +14 -6
- package/dist/core/domain/dto.d.ts +32 -2
- package/dist/core/domain/dto.js +23 -0
- package/dist/core/domain/pipeline.d.ts +89 -0
- package/dist/core/domain/pipeline.js +1 -0
- package/dist/core/domain/services.d.ts +14 -4
- package/dist/core/domain/services.js +4 -1
- package/dist/core/entrypoint/bounded-context.d.ts +79 -10
- package/dist/core/entrypoint/bounded-context.js +207 -33
- 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 +37 -6
- package/dist/core/services/execution.js +86 -7
- 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 +67 -0
- package/dist/event_driven/adapters/stored-queue.js +192 -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 +34 -0
- package/dist/event_driven/entrypoint/subscriber.js +51 -0
- package/dist/event_driven/index.d.ts +19 -0
- package/dist/event_driven/index.js +17 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +7 -2
- package/dist/observability/domain/logging.d.ts +2 -0
- package/dist/observability/services/logging.js +1 -0
- package/dist/observability/services/outcome.d.ts +6 -6
- package/dist/observability/services/outcome.js +10 -13
- package/package.json +1 -1
|
@@ -1,18 +1,45 @@
|
|
|
1
1
|
import type { Execution } from "../../context/domain/execution.js";
|
|
2
2
|
import type { ContextValues } from "../../context/domain/store.js";
|
|
3
|
+
import type { Executable } from "../domain/dto.js";
|
|
3
4
|
import type { ErrorHandler } from "../domain/errors.js";
|
|
5
|
+
import type { Cause, ExecutionCompleted, ExecutionStarted, Interceptor, PipelineLayer } from "../domain/pipeline.js";
|
|
6
|
+
import { type Attributes, type Outcome } from "../../observability/domain/tracing.js";
|
|
4
7
|
export interface RuntimeService {
|
|
5
8
|
execute(command: object): unknown;
|
|
6
9
|
}
|
|
7
|
-
type Layer =
|
|
10
|
+
type Layer = PipelineLayer;
|
|
11
|
+
/** Where a failure first happened: the run, its executable, its layer and how it ended. */
|
|
12
|
+
export type FailedAt = {
|
|
13
|
+
readonly execution: Execution;
|
|
14
|
+
readonly executable: Executable;
|
|
15
|
+
readonly layer: Layer;
|
|
16
|
+
readonly outcome: Outcome;
|
|
17
|
+
};
|
|
18
|
+
/** Where `error` first failed, when it is an object a run threw; `undefined` otherwise. */
|
|
19
|
+
export declare function failedAt(error: unknown): FailedAt | undefined;
|
|
20
|
+
/** Who hears the moments of a run: read when they happen, so a listener added later hears. */
|
|
21
|
+
export interface Announcer {
|
|
22
|
+
started(heard: ExecutionStarted): void;
|
|
23
|
+
completed(heard: ExecutionCompleted): void;
|
|
24
|
+
}
|
|
8
25
|
export interface ExecutionOptions {
|
|
9
26
|
readonly boundedContext: string;
|
|
10
27
|
readonly name: string;
|
|
11
28
|
readonly layer: Layer;
|
|
29
|
+
/** What else its span says: the event it executes, the service it replaced. */
|
|
30
|
+
readonly attributes?: Attributes;
|
|
12
31
|
readonly dependencies: ReadonlyMap<string, unknown>;
|
|
13
32
|
readonly context: () => ContextValues;
|
|
14
33
|
readonly parent?: Execution;
|
|
34
|
+
/** What caused a root run from outside: a delivered event's envelope continues its chain. */
|
|
35
|
+
readonly cause?: Cause;
|
|
36
|
+
/** What cancels a root run; a child inherits its parent's. */
|
|
37
|
+
readonly signal?: AbortSignal;
|
|
15
38
|
readonly featureBus: (execution: Execution) => object;
|
|
39
|
+
/** What runs around the service, outermost first. */
|
|
40
|
+
readonly interceptors: readonly Interceptor[];
|
|
41
|
+
/** Who hears it start and answer. */
|
|
42
|
+
readonly announce: Announcer;
|
|
16
43
|
/** The bounded context's expected errors: they end `refused`, never reported. */
|
|
17
44
|
readonly expected: (error: unknown) => boolean;
|
|
18
45
|
/** The bounded context's handlers for this layer: what answers a failure instead of raising it. */
|
|
@@ -23,13 +50,17 @@ export interface ExecutionOptions {
|
|
|
23
50
|
*
|
|
24
51
|
* 1. A span `<bounded context>/<serviceName>`, a child of the parent run when there is one, and
|
|
25
52
|
* the execution every dependency, `this.logger` and child Feature are bound to.
|
|
26
|
-
*
|
|
53
|
+
* Its chain: the parent's correlation (or the cause's, or its own id for a root), caused by
|
|
54
|
+
* the parent run or the delivered event.
|
|
55
|
+
* 2. Its start is announced; the interceptors run around the service, which sees the
|
|
56
|
+
* execution through its own view of the instance.
|
|
27
57
|
* 3. A failure is observed where it happened: its outcome (expected by the bounded context →
|
|
28
|
-
* `refused`, else the process's `outcomeOf`), the span closed with it, its log line
|
|
29
|
-
* when `failed`, and only once per error along the run — its
|
|
58
|
+
* `refused`, else the process's `outcomeOf`), the span closed with it, its log line,
|
|
59
|
+
* where it first failed and — when `failed`, and only once per error along the run — its
|
|
60
|
+
* report.
|
|
30
61
|
* 4. Then the layer's error handlers answer it, in order; with none, or when the last throws,
|
|
31
62
|
* the error reaches the caller.
|
|
32
|
-
* Final: the service's answer
|
|
63
|
+
* Final: the service's answer — announced —, or its handlers'.
|
|
33
64
|
*/
|
|
34
|
-
export declare function executeService(target: RuntimeService, command:
|
|
65
|
+
export declare function executeService(target: RuntimeService, command: Executable, options: ExecutionOptions): Promise<unknown>;
|
|
35
66
|
export {};
|
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import { newEntityId } from "../../common/ids.js";
|
|
2
2
|
import { bindDependency } from "./binding.js";
|
|
3
3
|
import { handled } from "./error-handlers.js";
|
|
4
|
+
import { runAround } from "./interceptors.js";
|
|
4
5
|
import { NO_TRACER, } from "../../observability/domain/tracing.js";
|
|
5
6
|
import { observabilityNow } from "../../observability/infrastructure/registry.js";
|
|
6
7
|
import { executionLogger, logExecution, logHandled, } from "../../observability/services/logging.js";
|
|
7
8
|
import { firstReport } from "../../observability/services/reporting.js";
|
|
9
|
+
const FAILED_AT = new WeakMap();
|
|
10
|
+
/** Where `error` first failed, when it is an object a run threw; `undefined` otherwise. */
|
|
11
|
+
export function failedAt(error) {
|
|
12
|
+
return error !== null && typeof error === "object" ? FAILED_AT.get(error) : undefined;
|
|
13
|
+
}
|
|
8
14
|
const REPORTED_BY_EXECUTION = new WeakMap();
|
|
15
|
+
/** What a cancelled run fails with: an `AbortError`, whatever the signal was aborted with. */
|
|
16
|
+
function abortedBy(signal) {
|
|
17
|
+
const reason = signal.reason;
|
|
18
|
+
if (reason instanceof Error && reason.name === "AbortError")
|
|
19
|
+
return reason;
|
|
20
|
+
return Object.assign(new Error("The run was cancelled"), {
|
|
21
|
+
name: "AbortError",
|
|
22
|
+
cause: reason,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* What `running` answers — or the cancellation, the moment `signal` aborts: the caller stops
|
|
27
|
+
* waiting even when the service does not look at its signal.
|
|
28
|
+
*/
|
|
29
|
+
function untilAborted(running, signal) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const abort = () => reject(abortedBy(signal));
|
|
32
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
33
|
+
running.then((answer) => {
|
|
34
|
+
signal.removeEventListener("abort", abort);
|
|
35
|
+
resolve(answer);
|
|
36
|
+
}, (error) => {
|
|
37
|
+
signal.removeEventListener("abort", abort);
|
|
38
|
+
reject(error);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
9
42
|
/** Observability adapters are best-effort; their failures must not replace a business result. */
|
|
10
43
|
function observe(work, fallback) {
|
|
11
44
|
try {
|
|
@@ -27,6 +60,10 @@ function serviceView(target, options, execution) {
|
|
|
27
60
|
return execution.span;
|
|
28
61
|
if (property === "logger")
|
|
29
62
|
return logger;
|
|
63
|
+
if (property === "signal")
|
|
64
|
+
return execution.signal;
|
|
65
|
+
if (property === "execution")
|
|
66
|
+
return execution;
|
|
30
67
|
if (property === "featureBus" && options.layer === "application_service") {
|
|
31
68
|
return (features ??= options.featureBus(execution));
|
|
32
69
|
}
|
|
@@ -47,6 +84,8 @@ function serviceView(target, options, execution) {
|
|
|
47
84
|
if (property === "context" ||
|
|
48
85
|
property === "span" ||
|
|
49
86
|
property === "logger" ||
|
|
87
|
+
property === "signal" ||
|
|
88
|
+
property === "execution" ||
|
|
50
89
|
property === "featureBus" ||
|
|
51
90
|
(typeof property === "string" && options.dependencies.has(property))) {
|
|
52
91
|
throw new Error(`Execution dependencies and context are read-only: ${String(property)}`);
|
|
@@ -60,13 +99,17 @@ function serviceView(target, options, execution) {
|
|
|
60
99
|
*
|
|
61
100
|
* 1. A span `<bounded context>/<serviceName>`, a child of the parent run when there is one, and
|
|
62
101
|
* the execution every dependency, `this.logger` and child Feature are bound to.
|
|
63
|
-
*
|
|
102
|
+
* Its chain: the parent's correlation (or the cause's, or its own id for a root), caused by
|
|
103
|
+
* the parent run or the delivered event.
|
|
104
|
+
* 2. Its start is announced; the interceptors run around the service, which sees the
|
|
105
|
+
* execution through its own view of the instance.
|
|
64
106
|
* 3. A failure is observed where it happened: its outcome (expected by the bounded context →
|
|
65
|
-
* `refused`, else the process's `outcomeOf`), the span closed with it, its log line
|
|
66
|
-
* when `failed`, and only once per error along the run — its
|
|
107
|
+
* `refused`, else the process's `outcomeOf`), the span closed with it, its log line,
|
|
108
|
+
* where it first failed and — when `failed`, and only once per error along the run — its
|
|
109
|
+
* report.
|
|
67
110
|
* 4. Then the layer's error handlers answer it, in order; with none, or when the last throws,
|
|
68
111
|
* the error reaches the caller.
|
|
69
|
-
* Final: the service's answer
|
|
112
|
+
* Final: the service's answer — announced —, or its handlers'.
|
|
70
113
|
*/
|
|
71
114
|
export async function executeService(target, command, options) {
|
|
72
115
|
const ports = observabilityNow();
|
|
@@ -74,11 +117,16 @@ export async function executeService(target, command, options) {
|
|
|
74
117
|
const useCase = `${options.boundedContext}.${options.name}`;
|
|
75
118
|
const context = options.parent?.context ?? options.context();
|
|
76
119
|
const id = newEntityId();
|
|
120
|
+
const correlationId = options.parent?.correlationId ?? options.cause?.correlationId ?? id;
|
|
121
|
+
const causationId = options.parent?.id ?? options.cause?.id;
|
|
77
122
|
const attributes = {
|
|
78
123
|
"sincpro.use_case": useCase,
|
|
79
124
|
"sincpro.layer": options.layer,
|
|
80
125
|
"sincpro.context": options.boundedContext,
|
|
81
126
|
"sincpro.execution_id": id,
|
|
127
|
+
"sincpro.correlation_id": correlationId,
|
|
128
|
+
...(causationId ? { "sincpro.causation_id": causationId } : {}),
|
|
129
|
+
...options.attributes,
|
|
82
130
|
};
|
|
83
131
|
const span = observe(() => ports.tracer.start(`${options.boundedContext}/${options.name}`, {
|
|
84
132
|
...(options.parent ? { parent: options.parent.span } : {}),
|
|
@@ -89,6 +137,9 @@ export async function executeService(target, command, options) {
|
|
|
89
137
|
useCase,
|
|
90
138
|
boundedContext: options.boundedContext,
|
|
91
139
|
parent: options.parent,
|
|
140
|
+
correlationId,
|
|
141
|
+
causationId,
|
|
142
|
+
signal: options.signal ?? options.parent?.signal,
|
|
92
143
|
span,
|
|
93
144
|
context,
|
|
94
145
|
};
|
|
@@ -97,6 +148,7 @@ export async function executeService(target, command, options) {
|
|
|
97
148
|
: new Set();
|
|
98
149
|
REPORTED_BY_EXECUTION.set(execution, reported);
|
|
99
150
|
logExecution(options.layer, execution);
|
|
151
|
+
observe(() => options.announce.started({ executable: command, execution, layer: options.layer }), undefined);
|
|
100
152
|
const finish = (error) => {
|
|
101
153
|
const outcome = error
|
|
102
154
|
? observe(() => (options.expected(error.value) ? "refused" : ports.outcomeOf(error.value)), "failed")
|
|
@@ -117,12 +169,32 @@ export async function executeService(target, command, options) {
|
|
|
117
169
|
context,
|
|
118
170
|
}), undefined);
|
|
119
171
|
}
|
|
172
|
+
if (error?.value !== null &&
|
|
173
|
+
typeof error?.value === "object" &&
|
|
174
|
+
!FAILED_AT.has(error.value))
|
|
175
|
+
FAILED_AT.set(error.value, {
|
|
176
|
+
execution,
|
|
177
|
+
executable: command,
|
|
178
|
+
layer: options.layer,
|
|
179
|
+
outcome,
|
|
180
|
+
});
|
|
181
|
+
return outcome;
|
|
120
182
|
};
|
|
183
|
+
const view = serviceView(target, options, execution);
|
|
184
|
+
const service = (current) => Promise.resolve(Reflect.apply(target.execute, view, [current]));
|
|
121
185
|
let answer;
|
|
122
186
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
187
|
+
const signal = execution.signal;
|
|
188
|
+
if (signal?.aborted)
|
|
189
|
+
throw abortedBy(signal);
|
|
190
|
+
const running = runAround(options.interceptors, service, command, {
|
|
191
|
+
boundedContext: options.boundedContext,
|
|
192
|
+
useCase,
|
|
193
|
+
layer: options.layer,
|
|
194
|
+
execution,
|
|
195
|
+
logger: executionLogger(options.layer, execution),
|
|
196
|
+
});
|
|
197
|
+
answer = signal === undefined ? await running : await untilAborted(running, signal);
|
|
126
198
|
}
|
|
127
199
|
catch (error) {
|
|
128
200
|
finish({ value: error });
|
|
@@ -133,5 +205,12 @@ export async function executeService(target, command, options) {
|
|
|
133
205
|
return recovered;
|
|
134
206
|
}
|
|
135
207
|
finish();
|
|
208
|
+
observe(() => options.announce.completed({
|
|
209
|
+
executable: command,
|
|
210
|
+
execution,
|
|
211
|
+
layer: options.layer,
|
|
212
|
+
answer,
|
|
213
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
214
|
+
}), undefined);
|
|
136
215
|
return answer;
|
|
137
216
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Executable } from "../domain/dto.js";
|
|
2
|
+
import type { InterceptedRun, Interceptor } from "../domain/pipeline.js";
|
|
3
|
+
/**
|
|
4
|
+
* `handler` run on `executable`, wrapped by `interceptors` — the first the outermost.
|
|
5
|
+
*
|
|
6
|
+
* runAround([], service, command, run) -> the service's answer
|
|
7
|
+
* runAround([audit, credit], service, command, run) -> audit(credit(service))
|
|
8
|
+
* runAround([<passes another class on>], …) -> TypeError, naming both classes
|
|
9
|
+
*
|
|
10
|
+
* 1. Each interceptor gets a `next` bound to the position after it.
|
|
11
|
+
* 2. What reaches `next` must be of the class the call started with: the bus routes by class,
|
|
12
|
+
* so another class would be another use case under this one's name.
|
|
13
|
+
* Final: the outermost interceptor's answer — the service's, when none answers by itself.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runAround(interceptors: readonly Interceptor[], handler: (executable: Executable) => Promise<unknown>, executable: Executable, run: InterceptedRun): Promise<unknown>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `handler` run on `executable`, wrapped by `interceptors` — the first the outermost.
|
|
3
|
+
*
|
|
4
|
+
* runAround([], service, command, run) -> the service's answer
|
|
5
|
+
* runAround([audit, credit], service, command, run) -> audit(credit(service))
|
|
6
|
+
* runAround([<passes another class on>], …) -> TypeError, naming both classes
|
|
7
|
+
*
|
|
8
|
+
* 1. Each interceptor gets a `next` bound to the position after it.
|
|
9
|
+
* 2. What reaches `next` must be of the class the call started with: the bus routes by class,
|
|
10
|
+
* so another class would be another use case under this one's name.
|
|
11
|
+
* Final: the outermost interceptor's answer — the service's, when none answers by itself.
|
|
12
|
+
*/
|
|
13
|
+
export function runAround(interceptors, handler, executable, run) {
|
|
14
|
+
const started = Object.getPrototypeOf(executable)?.constructor;
|
|
15
|
+
const at = async (position, current) => {
|
|
16
|
+
if (Object.getPrototypeOf(current)?.constructor !== started) {
|
|
17
|
+
throw new TypeError(`An interceptor of ${run.useCase} passed on a ${current?.constructor?.name} where it received a ${started?.name}`);
|
|
18
|
+
}
|
|
19
|
+
const interceptor = interceptors[position];
|
|
20
|
+
if (interceptor === undefined)
|
|
21
|
+
return handler(current);
|
|
22
|
+
return interceptor(current, (next) => at(position + 1, next), run);
|
|
23
|
+
};
|
|
24
|
+
return at(0, executable);
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Executable } from "../domain/dto.js";
|
|
2
|
+
import type { Listener } from "../domain/pipeline.js";
|
|
3
|
+
/** A class a service, an interceptor or a listener is for. */
|
|
4
|
+
export type ExecutableClass = abstract new (...args: never[]) => Executable;
|
|
5
|
+
/** An interceptor or a listener, and the classes it is for — every class when it names none. */
|
|
6
|
+
export type Hearing<F> = {
|
|
7
|
+
readonly what: F;
|
|
8
|
+
readonly dtos: readonly ExecutableClass[];
|
|
9
|
+
};
|
|
10
|
+
/** Whether something registered for `dtos` concerns `executable`: by its exact class. */
|
|
11
|
+
export declare function concerns(dtos: readonly ExecutableClass[], executable: Executable): boolean;
|
|
12
|
+
/** Each listener that hears it, in turn; one that fails is ignored. */
|
|
13
|
+
export declare function announceTo<E extends {
|
|
14
|
+
executable: Executable;
|
|
15
|
+
}>(hearing: ReadonlySet<Hearing<Listener<E>>>, heard: E): void;
|
|
16
|
+
/** `listener` added to `hearing` for `dtos`; answers how to stop. */
|
|
17
|
+
export declare function listenIn<E>(hearing: Set<Hearing<Listener<E>>>, listener: Listener<E>, dtos: readonly ExecutableClass[]): () => void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Whether something registered for `dtos` concerns `executable`: by its exact class. */
|
|
2
|
+
export function concerns(dtos, executable) {
|
|
3
|
+
return dtos.length === 0 || dtos.includes(Object.getPrototypeOf(executable)?.constructor);
|
|
4
|
+
}
|
|
5
|
+
/** Each listener that hears it, in turn; one that fails is ignored. */
|
|
6
|
+
export function announceTo(hearing, heard) {
|
|
7
|
+
for (const one of [...hearing]) {
|
|
8
|
+
if (!concerns(one.dtos, heard.executable))
|
|
9
|
+
continue;
|
|
10
|
+
try {
|
|
11
|
+
one.what(heard);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Hearing never changes the answer, as tracing, reporting and logging never do.
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** `listener` added to `hearing` for `dtos`; answers how to stop. */
|
|
19
|
+
export function listenIn(hearing, listener, dtos) {
|
|
20
|
+
if (typeof listener !== "function")
|
|
21
|
+
throw new TypeError("A listener is a function");
|
|
22
|
+
const one = { what: listener, dtos };
|
|
23
|
+
hearing.add(one);
|
|
24
|
+
return () => void hearing.delete(one);
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EventStore, StoredEvent } from "../domain/store.js";
|
|
2
|
+
/** A memory store, and what it holds — in the order added — for whoever inspects it. */
|
|
3
|
+
export type MemoryEventStore = EventStore & {
|
|
4
|
+
readonly events: readonly StoredEvent[];
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The events of a stored queue in this process's memory: what a browser tab or a test keeps.
|
|
8
|
+
* Gone with the process — a phone's store is a table.
|
|
9
|
+
*/
|
|
10
|
+
export declare function memoryEventStore(): MemoryEventStore;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The events of a stored queue in this process's memory: what a browser tab or a test keeps.
|
|
3
|
+
* Gone with the process — a phone's store is a table.
|
|
4
|
+
*/
|
|
5
|
+
export function memoryEventStore() {
|
|
6
|
+
const held = [];
|
|
7
|
+
return {
|
|
8
|
+
get events() {
|
|
9
|
+
return [...held];
|
|
10
|
+
},
|
|
11
|
+
add(stored) {
|
|
12
|
+
held.push(stored);
|
|
13
|
+
},
|
|
14
|
+
next(skip) {
|
|
15
|
+
return held.find((one) => one.state === "pending" && !skip.has(one.envelope.id));
|
|
16
|
+
},
|
|
17
|
+
save(stored) {
|
|
18
|
+
const at = held.findIndex((one) => one.envelope.id === stored.envelope.id);
|
|
19
|
+
if (at >= 0)
|
|
20
|
+
held[at] = stored;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type JsonValue } from "../../common/serialization.js";
|
|
2
|
+
import type { DomainEvent, EventClass } from "../../core/domain/dto.js";
|
|
3
|
+
import type { EventQueue, Subscribes } from "../domain/queue.js";
|
|
4
|
+
import type { EventStore, StoredEvent } from "../domain/store.js";
|
|
5
|
+
export type StoredQueueOptions = {
|
|
6
|
+
/** Where the events wait: memory, a phone's SQLite, a browser's IndexedDB. */
|
|
7
|
+
readonly store: EventStore;
|
|
8
|
+
/** Who executes them: every bounded context that hears each one. */
|
|
9
|
+
readonly subscriber: Subscribes;
|
|
10
|
+
/**
|
|
11
|
+
* How a stored payload becomes its class again. By default, `static fromJSON` when the class
|
|
12
|
+
* has one, else its values set on an instance of it — as JSON gave them: an event that carries
|
|
13
|
+
* a `Money`, a `Decimal` or a date declares `fromJSON` to read them back as such.
|
|
14
|
+
*/
|
|
15
|
+
readonly rebuild?: (type: EventClass<DomainEvent>, payload: JsonValue) => DomainEvent;
|
|
16
|
+
/**
|
|
17
|
+
* Whether an event may be delivered now — a phone asks for a connection, or a session. One
|
|
18
|
+
* that is not waits while the rest go on; `drain()` again when what it waits for comes back.
|
|
19
|
+
*/
|
|
20
|
+
readonly ready?: (stored: StoredEvent) => boolean | Promise<boolean>;
|
|
21
|
+
/** The first wait after a failed attempt, in ms (1 s); doubled each time, never past `longestWait`. */
|
|
22
|
+
readonly wait?: number;
|
|
23
|
+
/** The longest wait between attempts, in ms (one minute). */
|
|
24
|
+
readonly longestWait?: number;
|
|
25
|
+
/**
|
|
26
|
+
* How many attempts an event gets before it is set aside although a retry might still fix it
|
|
27
|
+
* (10): a bug of this process (`internal`) or a dependency gone for good must not hold the line
|
|
28
|
+
* forever. `Infinity` keeps trying.
|
|
29
|
+
*/
|
|
30
|
+
readonly maxAttempts?: number;
|
|
31
|
+
/** Told each time an event settles — delivered, set aside, or waiting to retry —: a screen's activity. */
|
|
32
|
+
readonly onSettled?: (stored: StoredEvent) => void;
|
|
33
|
+
};
|
|
34
|
+
/** A stored queue, and the door to deliver what waits — on a timer, back online, in the foreground. */
|
|
35
|
+
export type StoredQueue = EventQueue & {
|
|
36
|
+
drain(): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The queue that stores an event before delivering it, and delivers it until every bounded
|
|
40
|
+
* context that hears it has it — the backend's store-and-forward (`Forwarding`), over a store
|
|
41
|
+
* each platform plugs.
|
|
42
|
+
*
|
|
43
|
+
* const queue = storedQueue({ store: sqliteEventStore(db), subscriber, ready: online });
|
|
44
|
+
* shop.addDependency("publisher", new Publisher(queue));
|
|
45
|
+
* connectivity.onBack(() => queue.drain());
|
|
46
|
+
*
|
|
47
|
+
* Context: what a phone did by hand in its event bus, the same on every platform. `publish`
|
|
48
|
+
* returns once the event is stored — nobody waits for the delivery. Then, one at a time and in
|
|
49
|
+
* the order stored:
|
|
50
|
+
*
|
|
51
|
+
* 1. An event not ready (`ready`) waits while the rest go on.
|
|
52
|
+
* 2. Nobody hears its name: delivered — a fact recorded, nobody's to act on.
|
|
53
|
+
* 3. Rebuilt as its class; one that cannot be is set aside.
|
|
54
|
+
* 4. Executed in every bounded context that hears it and does not have it yet (`reached`).
|
|
55
|
+
* 5. A failure every attempt would repeat (`PERMANENT_KINDS`: a refusal, invalid, not found) sets
|
|
56
|
+
* that context aside; any other is tried again after the backoff — or what the failure asks
|
|
57
|
+
* for (`retryAfter`, an open circuit's time left) —, and what comes after waits, so the
|
|
58
|
+
* order holds; after `maxAttempts`, it is set aside too.
|
|
59
|
+
* Final: delivered when every context has it, set aside — the dead-letter queue — when one
|
|
60
|
+
* refused it for good or ran out of attempts; `onSettled` hears either.
|
|
61
|
+
*
|
|
62
|
+
* Delivery is at least once: a process that stops after a context executed an event and before
|
|
63
|
+
* the store saved it executes it there again on the next drain. A subscriber that must not act
|
|
64
|
+
* twice keys what it does by the envelope's id — `this.execution.causationId` in its run. And one
|
|
65
|
+
* process drains a store: two tabs over one IndexedDB claim it first (the Web Locks API).
|
|
66
|
+
*/
|
|
67
|
+
export declare function storedQueue(options: StoredQueueOptions): StoredQueue;
|
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
* shop.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. A subscriber that must not act
|
|
45
|
+
* twice keys what it does by the envelope's id — `this.execution.causationId` in its run. And one
|
|
46
|
+
* process drains a store: two tabs over one IndexedDB claim it first (the Web Locks API).
|
|
47
|
+
*/
|
|
48
|
+
export function storedQueue(options) {
|
|
49
|
+
const { store, subscriber } = options;
|
|
50
|
+
const firstWait = options.wait ?? 1000;
|
|
51
|
+
const longestWait = options.longestWait ?? 60_000;
|
|
52
|
+
const maxAttempts = options.maxAttempts ?? 10;
|
|
53
|
+
if (!(maxAttempts >= 1))
|
|
54
|
+
throw new TypeError("A stored queue needs one attempt at least");
|
|
55
|
+
const rebuild = options.rebuild ?? rebuildEvent;
|
|
56
|
+
let draining;
|
|
57
|
+
let again = false;
|
|
58
|
+
let timer;
|
|
59
|
+
const settle = async (stored) => {
|
|
60
|
+
await store.save(stored);
|
|
61
|
+
try {
|
|
62
|
+
options.onSettled?.(stored);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Telling a screen never changes a delivery.
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const wakeAt = (at) => {
|
|
69
|
+
if (timer !== undefined)
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
timer = setTimeout(() => {
|
|
72
|
+
timer = undefined;
|
|
73
|
+
void queue.drain();
|
|
74
|
+
}, Math.max(0, at - Date.now()));
|
|
75
|
+
// A timer of its own must not keep a process alive that has nothing else to do.
|
|
76
|
+
timer.unref?.();
|
|
77
|
+
};
|
|
78
|
+
/** One pass over what waits; it stops at an event that must wait its turn. */
|
|
79
|
+
const pass = async () => {
|
|
80
|
+
const skip = new Set();
|
|
81
|
+
for (;;) {
|
|
82
|
+
const head = await store.next(skip);
|
|
83
|
+
if (head === undefined)
|
|
84
|
+
return;
|
|
85
|
+
const id = head.envelope.id;
|
|
86
|
+
if (head.retryAt !== null && Date.parse(head.retryAt) > Date.now()) {
|
|
87
|
+
wakeAt(Date.parse(head.retryAt));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (options.ready && !(await options.ready(head))) {
|
|
91
|
+
skip.add(id);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const attempts = head.attempts + 1;
|
|
95
|
+
const type = subscriber.eventTypeOf(head.envelope.name);
|
|
96
|
+
if (type === undefined) {
|
|
97
|
+
await settle({ ...head, state: "delivered", attempts, retryAt: null, failures: [] });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
let event;
|
|
101
|
+
try {
|
|
102
|
+
event = rebuild(type, head.payload);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
await settle({
|
|
106
|
+
...head,
|
|
107
|
+
state: "set_aside",
|
|
108
|
+
attempts,
|
|
109
|
+
retryAt: null,
|
|
110
|
+
failures: [described("", error)],
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const handled = await subscriber.handle(event, head.envelope, head.reached);
|
|
115
|
+
const reached = [...head.reached];
|
|
116
|
+
const failures = [];
|
|
117
|
+
let retryAfter = 0;
|
|
118
|
+
let repeat = false;
|
|
119
|
+
for (const one of handled) {
|
|
120
|
+
if (!("error" in one)) {
|
|
121
|
+
reached.push(one.boundedContext);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
failures.push(described(one.boundedContext, one.error));
|
|
125
|
+
const failure = failureOf(one.error);
|
|
126
|
+
if (failure.permanent)
|
|
127
|
+
reached.push(one.boundedContext);
|
|
128
|
+
else {
|
|
129
|
+
repeat = true;
|
|
130
|
+
retryAfter = Math.max(retryAfter, (failure.retryAfter ?? 0) * 1000);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (repeat && attempts < maxAttempts) {
|
|
134
|
+
const backoff = Math.min(longestWait, firstWait * 2 ** (attempts - 1));
|
|
135
|
+
const at = Date.now() + Math.max(backoff, retryAfter);
|
|
136
|
+
await settle({
|
|
137
|
+
...head,
|
|
138
|
+
state: "pending",
|
|
139
|
+
attempts,
|
|
140
|
+
retryAt: new Date(at).toISOString(),
|
|
141
|
+
reached,
|
|
142
|
+
failures,
|
|
143
|
+
});
|
|
144
|
+
wakeAt(at);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
await settle({
|
|
148
|
+
...head,
|
|
149
|
+
state: failures.length === 0 ? "delivered" : "set_aside",
|
|
150
|
+
attempts,
|
|
151
|
+
retryAt: null,
|
|
152
|
+
reached,
|
|
153
|
+
failures,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const queue = {
|
|
158
|
+
async put(event, envelope) {
|
|
159
|
+
await store.add({
|
|
160
|
+
envelope,
|
|
161
|
+
payload: valuesOf(event),
|
|
162
|
+
state: "pending",
|
|
163
|
+
attempts: 0,
|
|
164
|
+
retryAt: null,
|
|
165
|
+
reached: [],
|
|
166
|
+
failures: [],
|
|
167
|
+
});
|
|
168
|
+
queue.drain().catch((error) => loggerAdapter.error("The stored queue could not deliver", {
|
|
169
|
+
reason: failureOf(error).reason,
|
|
170
|
+
}));
|
|
171
|
+
},
|
|
172
|
+
drain() {
|
|
173
|
+
if (draining !== undefined) {
|
|
174
|
+
again = true;
|
|
175
|
+
return draining;
|
|
176
|
+
}
|
|
177
|
+
draining = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
do {
|
|
180
|
+
again = false;
|
|
181
|
+
await pass();
|
|
182
|
+
} while (again);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
draining = undefined;
|
|
186
|
+
}
|
|
187
|
+
})();
|
|
188
|
+
return draining;
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
return queue;
|
|
192
|
+
}
|
|
@@ -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
|
+
}
|