@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
package/dist/core/index.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* infrastructure/ `configureProcess`, `configureLogger`
|
|
10
10
|
*/
|
|
11
11
|
export { FeatureBus } from "./domain/bus.js";
|
|
12
|
-
export { Command, DataTransferObject, Response, } from "./domain/dto.js";
|
|
12
|
+
export { Command, DataTransferObject, DomainEvent, Response, } from "./domain/dto.js";
|
|
13
13
|
export { ApplicationService, Feature } from "./domain/services.js";
|
|
14
|
-
export { BoundedContext } from "./entrypoint/bounded-context.js";
|
|
14
|
+
export { BoundedContext, } from "./entrypoint/bounded-context.js";
|
|
15
15
|
export { screensTransport } from "./entrypoint/screens.js";
|
|
16
|
+
export { processListeners } from "./infrastructure/listeners.js";
|
|
16
17
|
export { configureLogger, configureProcess, } from "./infrastructure/process.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ExecutionCompleted, ExecutionFailed, ExecutionStarted, Listener } from "../domain/pipeline.js";
|
|
2
|
+
import { type ExecutableClass, type Hearing } from "../services/listening.js";
|
|
3
|
+
/**
|
|
4
|
+
* Who hears every bounded context of this process — the backend's process listeners: an
|
|
5
|
+
* activity indicator, a toast, an audit that must not be wired into each context.
|
|
6
|
+
*
|
|
7
|
+
* const stop = processListeners.onFailure((failed) => toast(failed.execution?.useCase));
|
|
8
|
+
*
|
|
9
|
+
* Context: a bounded context's own listeners (`projectBoundedContext.onStart`) hear it alone; these hear all of
|
|
10
|
+
* them, after its own. They live as long as the process — `configureProcess` does not clear
|
|
11
|
+
* them —, so whoever adds one keeps the function that stops it.
|
|
12
|
+
*/
|
|
13
|
+
declare const hearing: {
|
|
14
|
+
started: Set<Hearing<Listener<ExecutionStarted>>>;
|
|
15
|
+
completed: Set<Hearing<Listener<ExecutionCompleted>>>;
|
|
16
|
+
failed: Set<Hearing<Listener<ExecutionFailed>>>;
|
|
17
|
+
};
|
|
18
|
+
/** @internal — what the bounded contexts announce to, besides their own listeners. */
|
|
19
|
+
export declare function processHearing(): typeof hearing;
|
|
20
|
+
export declare const processListeners: {
|
|
21
|
+
/** Hears every run of every bounded context begin. Answers how to stop. */
|
|
22
|
+
onStart(listener: Listener<ExecutionStarted>, ...dtos: ExecutableClass[]): () => void;
|
|
23
|
+
/** Hears every service that answered, in every bounded context. */
|
|
24
|
+
onCompletion(listener: Listener<ExecutionCompleted>, ...dtos: ExecutableClass[]): () => void;
|
|
25
|
+
/** Hears every failure that escapes a call, once, where it failed. */
|
|
26
|
+
onFailure(listener: Listener<ExecutionFailed>, ...dtos: ExecutableClass[]): () => void;
|
|
27
|
+
};
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { listenIn, } from "../services/listening.js";
|
|
2
|
+
/**
|
|
3
|
+
* Who hears every bounded context of this process — the backend's process listeners: an
|
|
4
|
+
* activity indicator, a toast, an audit that must not be wired into each context.
|
|
5
|
+
*
|
|
6
|
+
* const stop = processListeners.onFailure((failed) => toast(failed.execution?.useCase));
|
|
7
|
+
*
|
|
8
|
+
* Context: a bounded context's own listeners (`projectBoundedContext.onStart`) hear it alone; these hear all of
|
|
9
|
+
* them, after its own. They live as long as the process — `configureProcess` does not clear
|
|
10
|
+
* them —, so whoever adds one keeps the function that stops it.
|
|
11
|
+
*/
|
|
12
|
+
const hearing = {
|
|
13
|
+
started: new Set(),
|
|
14
|
+
completed: new Set(),
|
|
15
|
+
failed: new Set(),
|
|
16
|
+
};
|
|
17
|
+
/** @internal — what the bounded contexts announce to, besides their own listeners. */
|
|
18
|
+
export function processHearing() {
|
|
19
|
+
return hearing;
|
|
20
|
+
}
|
|
21
|
+
export const processListeners = {
|
|
22
|
+
/** Hears every run of every bounded context begin. Answers how to stop. */
|
|
23
|
+
onStart(listener, ...dtos) {
|
|
24
|
+
return listenIn(hearing.started, listener, dtos);
|
|
25
|
+
},
|
|
26
|
+
/** Hears every service that answered, in every bounded context. */
|
|
27
|
+
onCompletion(listener, ...dtos) {
|
|
28
|
+
return listenIn(hearing.completed, listener, dtos);
|
|
29
|
+
},
|
|
30
|
+
/** Hears every failure that escapes a call, once, where it failed. */
|
|
31
|
+
onFailure(listener, ...dtos) {
|
|
32
|
+
return listenIn(hearing.failed, listener, dtos);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type Execution } from "../../context/domain/execution.js";
|
|
2
2
|
/**
|
|
3
|
-
* A dependency as the execution should see it: bound when it can be (`BIND`,
|
|
4
|
-
* as it is otherwise.
|
|
3
|
+
* A dependency as the execution should see it: bound when it can be (`BIND`, a `Transport`, an
|
|
4
|
+
* `OperationTransport`), as it is otherwise.
|
|
5
5
|
*/
|
|
6
6
|
export declare function bindDependency<T>(value: T, execution: Execution): T;
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isOperationTransport } from "../../client/domain/operation.js";
|
|
2
|
+
import { bindOperations, bindTransport, isTransport, } from "../../client/services/propagation.js";
|
|
2
3
|
import { BIND, isBindable, } from "../../context/domain/execution.js";
|
|
3
4
|
/**
|
|
4
|
-
* A dependency as the execution should see it: bound when it can be (`BIND`,
|
|
5
|
-
* as it is otherwise.
|
|
5
|
+
* A dependency as the execution should see it: bound when it can be (`BIND`, a `Transport`, an
|
|
6
|
+
* `OperationTransport`), as it is otherwise.
|
|
6
7
|
*/
|
|
7
8
|
export function bindDependency(value, execution) {
|
|
8
9
|
if (isBindable(value))
|
|
9
10
|
return value[BIND](execution);
|
|
10
11
|
if (isTransport(value))
|
|
11
12
|
return bindTransport(value, execution);
|
|
13
|
+
if (isOperationTransport(value))
|
|
14
|
+
return bindOperations(value, execution);
|
|
12
15
|
return value;
|
|
13
16
|
}
|
|
@@ -1,35 +1,82 @@
|
|
|
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";
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
import type { Cause, ExecutionCompleted, ExecutionStarted, Interceptor, PipelineLayer } from "../domain/pipeline.js";
|
|
6
|
+
import { type Attributes, type Outcome } from "../../observability/domain/tracing.js";
|
|
7
|
+
/**
|
|
8
|
+
* Where a run sits: a use case's layer (`PipelineLayer`), or an entrypoint that calls use cases —
|
|
9
|
+
* an `EventSubscriber`. Interceptors wrap and listeners hear use cases only.
|
|
10
|
+
*/
|
|
11
|
+
export type RunLayer = PipelineLayer | "entrypoint";
|
|
12
|
+
type Layer = RunLayer;
|
|
13
|
+
/** Where a failure first happened: the run, its executable, its layer and how it ended. */
|
|
14
|
+
export type FailedAt = {
|
|
15
|
+
readonly execution: Execution;
|
|
16
|
+
readonly executable: Executable;
|
|
17
|
+
readonly layer: Layer;
|
|
18
|
+
readonly outcome: Outcome;
|
|
19
|
+
};
|
|
20
|
+
/** Where `error` first failed, when it is an object a run threw; `undefined` otherwise. */
|
|
21
|
+
export declare function failedAt(error: unknown): FailedAt | undefined;
|
|
22
|
+
/** Who hears the moments of a run: read when they happen, so a listener added later hears. */
|
|
23
|
+
export interface Announcer {
|
|
24
|
+
started(heard: ExecutionStarted): void;
|
|
25
|
+
completed(heard: ExecutionCompleted): void;
|
|
6
26
|
}
|
|
7
|
-
type Layer = "feature" | "application_service";
|
|
8
27
|
export interface ExecutionOptions {
|
|
28
|
+
/** Its bounded context — or, for an entrypoint's run, the registry it belongs to. */
|
|
9
29
|
readonly boundedContext: string;
|
|
10
30
|
readonly name: string;
|
|
11
31
|
readonly layer: Layer;
|
|
32
|
+
/** The method that runs it: a use case's `execute`, an entrypoint's `handle`. */
|
|
33
|
+
readonly method?: "execute" | "handle";
|
|
34
|
+
/** What else its span says: the event it executes, the service it replaced. */
|
|
35
|
+
readonly attributes?: Attributes;
|
|
12
36
|
readonly dependencies: ReadonlyMap<string, unknown>;
|
|
13
37
|
readonly context: () => ContextValues;
|
|
14
38
|
readonly parent?: Execution;
|
|
15
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The run that called this root from outside its bounded context — an `EventSubscriber`, a
|
|
41
|
+
* Feature of another bounded context — through a bounded context bound to it (`BIND`): its span
|
|
42
|
+
* is the parent, its flow continues, it is the cause and its signal cancels this one; the
|
|
43
|
+
* context, dependencies and handlers stay this bounded context's.
|
|
44
|
+
*/
|
|
45
|
+
readonly caller?: Execution;
|
|
46
|
+
/** What caused a root run from outside: a delivered event's envelope continues its chain. */
|
|
47
|
+
readonly cause?: Cause;
|
|
48
|
+
/** What cancels a root run; a child inherits its parent's. */
|
|
49
|
+
readonly signal?: AbortSignal;
|
|
50
|
+
/** An ApplicationService's Features; no other layer has them. */
|
|
51
|
+
readonly featureBus?: (execution: Execution) => object;
|
|
52
|
+
/** What runs around the service, outermost first. */
|
|
53
|
+
readonly interceptors: readonly Interceptor[];
|
|
54
|
+
/** Who hears a use case start and answer; an entrypoint's run is not announced. */
|
|
55
|
+
readonly announce?: Announcer;
|
|
16
56
|
/** The bounded context's expected errors: they end `refused`, never reported. */
|
|
17
57
|
readonly expected: (error: unknown) => boolean;
|
|
18
58
|
/** The bounded context's handlers for this layer: what answers a failure instead of raising it. */
|
|
19
59
|
readonly handlers: readonly ErrorHandler[];
|
|
20
60
|
}
|
|
21
61
|
/**
|
|
22
|
-
* One pipeline for every registered Feature and ApplicationService
|
|
62
|
+
* One pipeline for every registered Feature and ApplicationService — and for every entrypoint's
|
|
63
|
+
* run that calls them (an `EventSubscriber`).
|
|
23
64
|
*
|
|
24
|
-
* 1. A span `<bounded context>/<serviceName>`, a child of the
|
|
25
|
-
* the execution every dependency, `this.logger` and
|
|
26
|
-
*
|
|
65
|
+
* 1. A span `<bounded context>/<serviceName>`, a child of the run above it when there is one (its
|
|
66
|
+
* parent, or the caller from outside), and the execution every dependency, `this.logger` and
|
|
67
|
+
* child Feature are bound to. Its chain: the run above's correlation (or the cause's, or its
|
|
68
|
+
* own id for a root), caused by the run above or the delivered event.
|
|
69
|
+
* 2. A use case's start is announced; the interceptors run around the service, which sees the
|
|
70
|
+
* execution through its own view of the instance. An entrypoint's run is neither announced
|
|
71
|
+
* nor intercepted.
|
|
27
72
|
* 3. A failure is observed where it happened: its outcome (expected by the bounded context →
|
|
28
|
-
* `refused
|
|
29
|
-
*
|
|
73
|
+
* `refused`; else the outcome it already had where it first failed — another bounded
|
|
74
|
+
* context's rules —; else the process's `outcomeOf`), the span closed with it, its log line,
|
|
75
|
+
* where it first failed and — when `failed`, and only once per error along the flow — its
|
|
76
|
+
* report.
|
|
30
77
|
* 4. Then the layer's error handlers answer it, in order; with none, or when the last throws,
|
|
31
78
|
* the error reaches the caller.
|
|
32
|
-
* Final: the service's answer
|
|
79
|
+
* Final: the service's answer — announced —, or its handlers'.
|
|
33
80
|
*/
|
|
34
|
-
export declare function executeService(target:
|
|
81
|
+
export declare function executeService(target: object, command: Executable, options: ExecutionOptions): Promise<unknown>;
|
|
35
82
|
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,7 +60,13 @@ function serviceView(target, options, execution) {
|
|
|
27
60
|
return execution.span;
|
|
28
61
|
if (property === "logger")
|
|
29
62
|
return logger;
|
|
30
|
-
if (property === "
|
|
63
|
+
if (property === "signal")
|
|
64
|
+
return execution.signal;
|
|
65
|
+
if (property === "execution")
|
|
66
|
+
return execution;
|
|
67
|
+
if (property === "featureBus" &&
|
|
68
|
+
options.layer === "application_service" &&
|
|
69
|
+
options.featureBus) {
|
|
31
70
|
return (features ??= options.featureBus(execution));
|
|
32
71
|
}
|
|
33
72
|
if (typeof property === "string" && options.dependencies.has(property)) {
|
|
@@ -47,6 +86,8 @@ function serviceView(target, options, execution) {
|
|
|
47
86
|
if (property === "context" ||
|
|
48
87
|
property === "span" ||
|
|
49
88
|
property === "logger" ||
|
|
89
|
+
property === "signal" ||
|
|
90
|
+
property === "execution" ||
|
|
50
91
|
property === "featureBus" ||
|
|
51
92
|
(typeof property === "string" && options.dependencies.has(property))) {
|
|
52
93
|
throw new Error(`Execution dependencies and context are read-only: ${String(property)}`);
|
|
@@ -56,17 +97,24 @@ function serviceView(target, options, execution) {
|
|
|
56
97
|
});
|
|
57
98
|
}
|
|
58
99
|
/**
|
|
59
|
-
* One pipeline for every registered Feature and ApplicationService
|
|
100
|
+
* One pipeline for every registered Feature and ApplicationService — and for every entrypoint's
|
|
101
|
+
* run that calls them (an `EventSubscriber`).
|
|
60
102
|
*
|
|
61
|
-
* 1. A span `<bounded context>/<serviceName>`, a child of the
|
|
62
|
-
* the execution every dependency, `this.logger` and
|
|
63
|
-
*
|
|
103
|
+
* 1. A span `<bounded context>/<serviceName>`, a child of the run above it when there is one (its
|
|
104
|
+
* parent, or the caller from outside), and the execution every dependency, `this.logger` and
|
|
105
|
+
* child Feature are bound to. Its chain: the run above's correlation (or the cause's, or its
|
|
106
|
+
* own id for a root), caused by the run above or the delivered event.
|
|
107
|
+
* 2. A use case's start is announced; the interceptors run around the service, which sees the
|
|
108
|
+
* execution through its own view of the instance. An entrypoint's run is neither announced
|
|
109
|
+
* nor intercepted.
|
|
64
110
|
* 3. A failure is observed where it happened: its outcome (expected by the bounded context →
|
|
65
|
-
* `refused
|
|
66
|
-
*
|
|
111
|
+
* `refused`; else the outcome it already had where it first failed — another bounded
|
|
112
|
+
* context's rules —; else the process's `outcomeOf`), the span closed with it, its log line,
|
|
113
|
+
* where it first failed and — when `failed`, and only once per error along the flow — its
|
|
114
|
+
* report.
|
|
67
115
|
* 4. Then the layer's error handlers answer it, in order; with none, or when the last throws,
|
|
68
116
|
* the error reaches the caller.
|
|
69
|
-
* Final: the service's answer
|
|
117
|
+
* Final: the service's answer — announced —, or its handlers'.
|
|
70
118
|
*/
|
|
71
119
|
export async function executeService(target, command, options) {
|
|
72
120
|
const ports = observabilityNow();
|
|
@@ -74,35 +122,51 @@ export async function executeService(target, command, options) {
|
|
|
74
122
|
const useCase = `${options.boundedContext}.${options.name}`;
|
|
75
123
|
const context = options.parent?.context ?? options.context();
|
|
76
124
|
const id = newEntityId();
|
|
125
|
+
// The run above this one: its parent in the bounded context, or the caller from outside.
|
|
126
|
+
const above = options.parent ?? options.caller;
|
|
127
|
+
const correlationId = above?.correlationId ?? options.cause?.correlationId ?? id;
|
|
128
|
+
const causationId = above?.id ?? options.cause?.id;
|
|
129
|
+
const layer = options.layer;
|
|
130
|
+
const useCaseLayer = layer === "entrypoint" ? undefined : layer;
|
|
77
131
|
const attributes = {
|
|
78
132
|
"sincpro.use_case": useCase,
|
|
79
133
|
"sincpro.layer": options.layer,
|
|
80
134
|
"sincpro.context": options.boundedContext,
|
|
81
135
|
"sincpro.execution_id": id,
|
|
136
|
+
"sincpro.correlation_id": correlationId,
|
|
137
|
+
...(causationId ? { "sincpro.causation_id": causationId } : {}),
|
|
138
|
+
...options.attributes,
|
|
82
139
|
};
|
|
83
140
|
const span = observe(() => ports.tracer.start(`${options.boundedContext}/${options.name}`, {
|
|
84
|
-
...(
|
|
141
|
+
...(above ? { parent: above.span } : {}),
|
|
85
142
|
attributes,
|
|
86
143
|
}), NO_TRACER.start(useCase, {}));
|
|
87
144
|
const execution = {
|
|
88
145
|
id,
|
|
89
146
|
useCase,
|
|
90
147
|
boundedContext: options.boundedContext,
|
|
91
|
-
parent:
|
|
148
|
+
parent: above,
|
|
149
|
+
correlationId,
|
|
150
|
+
causationId,
|
|
151
|
+
signal: options.signal ?? above?.signal,
|
|
92
152
|
span,
|
|
93
153
|
context,
|
|
94
154
|
};
|
|
95
|
-
const reported =
|
|
96
|
-
? (REPORTED_BY_EXECUTION.get(
|
|
155
|
+
const reported = above
|
|
156
|
+
? (REPORTED_BY_EXECUTION.get(above) ?? new Set())
|
|
97
157
|
: new Set();
|
|
98
158
|
REPORTED_BY_EXECUTION.set(execution, reported);
|
|
99
|
-
logExecution(
|
|
159
|
+
logExecution(layer, execution);
|
|
160
|
+
if (useCaseLayer)
|
|
161
|
+
observe(() => options.announce?.started({ executable: command, execution, layer: useCaseLayer }), undefined);
|
|
100
162
|
const finish = (error) => {
|
|
101
163
|
const outcome = error
|
|
102
|
-
? observe(() =>
|
|
164
|
+
? observe(() => options.expected(error.value)
|
|
165
|
+
? "refused"
|
|
166
|
+
: (failedAt(error.value)?.outcome ?? ports.outcomeOf(error.value)), "failed")
|
|
103
167
|
: "ok";
|
|
104
168
|
observe(() => span.end(outcome, error?.value), undefined);
|
|
105
|
-
logExecution(
|
|
169
|
+
logExecution(layer, execution, {
|
|
106
170
|
outcome,
|
|
107
171
|
durationMs: Math.max(0, Date.now() - startedAt),
|
|
108
172
|
});
|
|
@@ -117,21 +181,52 @@ export async function executeService(target, command, options) {
|
|
|
117
181
|
context,
|
|
118
182
|
}), undefined);
|
|
119
183
|
}
|
|
184
|
+
if (error?.value !== null &&
|
|
185
|
+
typeof error?.value === "object" &&
|
|
186
|
+
!FAILED_AT.has(error.value))
|
|
187
|
+
FAILED_AT.set(error.value, {
|
|
188
|
+
execution,
|
|
189
|
+
executable: command,
|
|
190
|
+
layer,
|
|
191
|
+
outcome,
|
|
192
|
+
});
|
|
193
|
+
return outcome;
|
|
120
194
|
};
|
|
195
|
+
const view = serviceView(target, options, execution);
|
|
196
|
+
const method = Reflect.get(target, options.method ?? "execute");
|
|
197
|
+
const service = (current) => Promise.resolve(Reflect.apply(method, view, [current]));
|
|
121
198
|
let answer;
|
|
122
199
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
200
|
+
const signal = execution.signal;
|
|
201
|
+
if (signal?.aborted)
|
|
202
|
+
throw abortedBy(signal);
|
|
203
|
+
const running = useCaseLayer
|
|
204
|
+
? runAround(options.interceptors, service, command, {
|
|
205
|
+
boundedContext: options.boundedContext,
|
|
206
|
+
useCase,
|
|
207
|
+
layer: useCaseLayer,
|
|
208
|
+
execution,
|
|
209
|
+
logger: executionLogger(useCaseLayer, execution),
|
|
210
|
+
})
|
|
211
|
+
: service(command);
|
|
212
|
+
answer = signal === undefined ? await running : await untilAborted(running, signal);
|
|
126
213
|
}
|
|
127
214
|
catch (error) {
|
|
128
215
|
finish({ value: error });
|
|
129
216
|
if (options.handlers.length === 0)
|
|
130
217
|
throw error;
|
|
131
218
|
const recovered = await handled(options.handlers, error);
|
|
132
|
-
logHandled(
|
|
219
|
+
logHandled(layer, { execution });
|
|
133
220
|
return recovered;
|
|
134
221
|
}
|
|
135
222
|
finish();
|
|
223
|
+
if (useCaseLayer)
|
|
224
|
+
observe(() => options.announce?.completed({
|
|
225
|
+
executable: command,
|
|
226
|
+
execution,
|
|
227
|
+
layer: useCaseLayer,
|
|
228
|
+
answer,
|
|
229
|
+
durationMs: Math.max(0, Date.now() - startedAt),
|
|
230
|
+
}), undefined);
|
|
136
231
|
return answer;
|
|
137
232
|
}
|
|
@@ -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,68 @@
|
|
|
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
|
+
* shopBoundedContext.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. What must not act twice keys what
|
|
64
|
+
* it does by the envelope's id — `this.execution.causationId` in a use case of the event, or in an
|
|
65
|
+
* `EventSubscriber`, which hands it to the Command it executes — or by the entity the event names.
|
|
66
|
+
* And one process drains a store: two tabs over one IndexedDB claim it first (the Web Locks API).
|
|
67
|
+
*/
|
|
68
|
+
export declare function storedQueue(options: StoredQueueOptions): StoredQueue;
|