@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,83 @@
|
|
|
1
|
+
import type { FailureKind, NextStep, Problem } from "@sincpro/criteria";
|
|
2
|
+
/**
|
|
3
|
+
* What kind of failure an error is, whatever raised it — the backend's `common/failures`, read
|
|
4
|
+
* on a client: a refusal the server explained, a request nobody answered, a run a screen
|
|
5
|
+
* cancelled, a local domain's own refusal, another backend's error a platform translated.
|
|
6
|
+
*
|
|
7
|
+
* failureOf(new TransportError("/pay", 409, problem)) -> { kind: "conflict", nextStep: "refresh_and_retry", … }
|
|
8
|
+
* failureOf(new TransportError("/pay", null, null)) -> { kind: "unknown_outcome", fault: true, … }
|
|
9
|
+
* failureOf(new CreditExhausted()) -> what the class declares (`failureKind`)
|
|
10
|
+
* failureOf(new TypeError("x")) -> { kind: "internal", fault: true, … }
|
|
11
|
+
*
|
|
12
|
+
* Context: every client decides the same things about a failure — show it or report it, retry it
|
|
13
|
+
* or set it aside, ask to sign in again —, and each app was deciding them its own way. The words
|
|
14
|
+
* are the wire's (`FailureKind`, `NextStep`, `Problem` from @sincpro/criteria), the rules the
|
|
15
|
+
* backend's: a retryable kind is retried after `retryAfter`; a permanent one fails the same way on
|
|
16
|
+
* every attempt and is set aside; a fault is the system's problem and is reported. An error says
|
|
17
|
+
* its own kind (`failureKind`, as the backend's errors do), so nothing here knows who raised it:
|
|
18
|
+
* a platform's adapter translates its backend's errors into errors that declare one.
|
|
19
|
+
*/
|
|
20
|
+
export type Failure = {
|
|
21
|
+
readonly kind: FailureKind;
|
|
22
|
+
/** UPPER_SNAKE and stable: what a client switches on. */
|
|
23
|
+
readonly reason: string;
|
|
24
|
+
/** What the caller does next. */
|
|
25
|
+
readonly nextStep: NextStep;
|
|
26
|
+
/** Whether sending the same thing again — as it is, or after a fresh read — may succeed. */
|
|
27
|
+
readonly retryable: boolean;
|
|
28
|
+
/** Seconds to wait before retrying; none when retrying does not help. */
|
|
29
|
+
readonly retryAfter: number | undefined;
|
|
30
|
+
/** It fails the same way on every attempt: a queue sets it aside, never retries it. */
|
|
31
|
+
readonly permanent: boolean;
|
|
32
|
+
/** The system's problem — a bug, a server down, no answer —, not an answer to the caller. */
|
|
33
|
+
readonly fault: boolean;
|
|
34
|
+
/** Whoever asked stopped waiting: not a failure of anyone. */
|
|
35
|
+
readonly aborted: boolean;
|
|
36
|
+
/** What the server said, when it said it. */
|
|
37
|
+
readonly problem: Problem | undefined;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* What an error can say about itself — on the instance or as a static of its class:
|
|
41
|
+
*
|
|
42
|
+
* class CreditExhausted extends Error {
|
|
43
|
+
* static readonly failureKind = "exhausted";
|
|
44
|
+
* readonly retryAfter = 30;
|
|
45
|
+
* }
|
|
46
|
+
*/
|
|
47
|
+
export type DeclaredFailure = {
|
|
48
|
+
readonly failureKind?: FailureKind;
|
|
49
|
+
readonly reason?: string;
|
|
50
|
+
readonly retryAfter?: number;
|
|
51
|
+
};
|
|
52
|
+
/** The kinds that fail the same way on every attempt — set aside at the first failure. */
|
|
53
|
+
export declare const PERMANENT_KINDS: ReadonlySet<FailureKind>;
|
|
54
|
+
/**
|
|
55
|
+
* The kind each wire's own code stands for, when an answer carries no `Problem` — the backend's
|
|
56
|
+
* tables, read back, so a transport of any wire (REST, JSON-RPC, gRPC, another backend's) builds
|
|
57
|
+
* errors that declare the same kinds:
|
|
58
|
+
*
|
|
59
|
+
* class RpcError extends Error {
|
|
60
|
+
* constructor(readonly code: number, message: string) { super(message); }
|
|
61
|
+
* get failureKind() { return kindOfJsonRpcCode(this.code); }
|
|
62
|
+
* }
|
|
63
|
+
*/
|
|
64
|
+
export declare function kindOfHttpStatus(status: number): FailureKind;
|
|
65
|
+
/** The kind a gRPC status code stands for — the backend's gRPC table, read back. */
|
|
66
|
+
export declare function kindOfGrpcCode(code: number): FailureKind;
|
|
67
|
+
/** The kind a JSON-RPC error code stands for — the backend's catalogue, read back. */
|
|
68
|
+
export declare function kindOfJsonRpcCode(code: number): FailureKind;
|
|
69
|
+
/**
|
|
70
|
+
* The failure `error` is.
|
|
71
|
+
*
|
|
72
|
+
* 1. Whoever asked stopped waiting (`AbortError`): aborted — a write may have run, so its kind is
|
|
73
|
+
* `unknown_outcome`, but it is nobody's failure.
|
|
74
|
+
* 2. What the error declares (`failureKind`, `reason`, `retryAfter`), on itself or its class.
|
|
75
|
+
* 3. What the server said (`problem`, a `Problem` — the same over REST, JSON-RPC and gRPC): its
|
|
76
|
+
* kind, reason, next step and wait.
|
|
77
|
+
* 4. A REST answer (`TransportError`) with a status and no problem: the kind its HTTP status
|
|
78
|
+
* stands for. Another wire's transport declares its kind instead (`kindOfGrpcCode`,
|
|
79
|
+
* `kindOfJsonRpcCode`).
|
|
80
|
+
* 5. A request that got no answer (`status: null`): `unknown_outcome` — it may have run.
|
|
81
|
+
* Final: anything else is `internal`, a fault.
|
|
82
|
+
*/
|
|
83
|
+
export declare function failureOf(error: unknown): Failure;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/** The kinds that fail the same way on every attempt — set aside at the first failure. */
|
|
2
|
+
export const PERMANENT_KINDS = new Set([
|
|
3
|
+
"invalid",
|
|
4
|
+
"unauthenticated",
|
|
5
|
+
"permission_denied",
|
|
6
|
+
"not_found",
|
|
7
|
+
"key_reused",
|
|
8
|
+
"domain",
|
|
9
|
+
]);
|
|
10
|
+
/** What the caller does next, by kind — the backend's `NEXT_STEP_OF`. */
|
|
11
|
+
const NEXT_STEP_OF = {
|
|
12
|
+
invalid: "fix_request",
|
|
13
|
+
unauthenticated: "authenticate",
|
|
14
|
+
permission_denied: "ask_for_access",
|
|
15
|
+
not_found: "do_not_retry",
|
|
16
|
+
conflict: "refresh_and_retry",
|
|
17
|
+
in_progress: "retry_later",
|
|
18
|
+
key_reused: "fix_request",
|
|
19
|
+
domain: "do_not_retry",
|
|
20
|
+
exhausted: "retry_later",
|
|
21
|
+
unavailable: "retry_later",
|
|
22
|
+
unknown_outcome: "verify_then_retry",
|
|
23
|
+
internal: "retry_later",
|
|
24
|
+
};
|
|
25
|
+
/** The kinds that are the system's problem: reported, never shown as the caller's answer. */
|
|
26
|
+
const FAULTS = new Set([
|
|
27
|
+
"internal",
|
|
28
|
+
"unavailable",
|
|
29
|
+
"unknown_outcome",
|
|
30
|
+
]);
|
|
31
|
+
const RETRYABLE_STEPS = new Set([
|
|
32
|
+
"retry_later",
|
|
33
|
+
"refresh_and_retry",
|
|
34
|
+
]);
|
|
35
|
+
/** A retryable kind that says nothing waits a second, as the backend's `DEFAULT_RETRY_AFTER`. */
|
|
36
|
+
const DEFAULT_RETRY_AFTER = 1;
|
|
37
|
+
const KINDS = new Set(Object.keys(NEXT_STEP_OF));
|
|
38
|
+
function isKind(value) {
|
|
39
|
+
return typeof value === "string" && KINDS.has(value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The kind each wire's own code stands for, when an answer carries no `Problem` — the backend's
|
|
43
|
+
* tables, read back, so a transport of any wire (REST, JSON-RPC, gRPC, another backend's) builds
|
|
44
|
+
* errors that declare the same kinds:
|
|
45
|
+
*
|
|
46
|
+
* class RpcError extends Error {
|
|
47
|
+
* constructor(readonly code: number, message: string) { super(message); }
|
|
48
|
+
* get failureKind() { return kindOfJsonRpcCode(this.code); }
|
|
49
|
+
* }
|
|
50
|
+
*/
|
|
51
|
+
export function kindOfHttpStatus(status) {
|
|
52
|
+
if (status === 401)
|
|
53
|
+
return "unauthenticated";
|
|
54
|
+
if (status === 403)
|
|
55
|
+
return "permission_denied";
|
|
56
|
+
if (status === 404 || status === 410)
|
|
57
|
+
return "not_found";
|
|
58
|
+
if (status === 409 || status === 412)
|
|
59
|
+
return "conflict";
|
|
60
|
+
if (status === 429)
|
|
61
|
+
return "exhausted";
|
|
62
|
+
if (status === 502 || status === 503)
|
|
63
|
+
return "unavailable";
|
|
64
|
+
if (status === 504)
|
|
65
|
+
return "unknown_outcome";
|
|
66
|
+
if (status >= 500)
|
|
67
|
+
return "internal";
|
|
68
|
+
return "invalid";
|
|
69
|
+
}
|
|
70
|
+
/** The kind a gRPC status code stands for — the backend's gRPC table, read back. */
|
|
71
|
+
export function kindOfGrpcCode(code) {
|
|
72
|
+
switch (code) {
|
|
73
|
+
case 3: // INVALID_ARGUMENT
|
|
74
|
+
case 11: // OUT_OF_RANGE
|
|
75
|
+
return "invalid";
|
|
76
|
+
case 16: // UNAUTHENTICATED
|
|
77
|
+
return "unauthenticated";
|
|
78
|
+
case 7: // PERMISSION_DENIED
|
|
79
|
+
return "permission_denied";
|
|
80
|
+
case 5: // NOT_FOUND
|
|
81
|
+
case 12: // UNIMPLEMENTED
|
|
82
|
+
return "not_found";
|
|
83
|
+
case 6: // ALREADY_EXISTS
|
|
84
|
+
case 10: // ABORTED — also an idempotent run in flight
|
|
85
|
+
return "conflict";
|
|
86
|
+
case 9: // FAILED_PRECONDITION — also a reused idempotency key
|
|
87
|
+
return "domain";
|
|
88
|
+
case 8: // RESOURCE_EXHAUSTED
|
|
89
|
+
return "exhausted";
|
|
90
|
+
case 14: // UNAVAILABLE
|
|
91
|
+
return "unavailable";
|
|
92
|
+
case 1: // CANCELLED
|
|
93
|
+
case 4: // DEADLINE_EXCEEDED
|
|
94
|
+
return "unknown_outcome";
|
|
95
|
+
default: // UNKNOWN, INTERNAL, DATA_LOSS
|
|
96
|
+
return "internal";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** The kind a JSON-RPC error code stands for — the backend's catalogue, read back. */
|
|
100
|
+
export function kindOfJsonRpcCode(code) {
|
|
101
|
+
switch (code) {
|
|
102
|
+
case -32700:
|
|
103
|
+
case -32600:
|
|
104
|
+
case -32602:
|
|
105
|
+
return "invalid";
|
|
106
|
+
case -32601:
|
|
107
|
+
case -32004:
|
|
108
|
+
return "not_found";
|
|
109
|
+
case -32000:
|
|
110
|
+
return "unavailable";
|
|
111
|
+
case -32001:
|
|
112
|
+
return "unauthenticated";
|
|
113
|
+
case -32003:
|
|
114
|
+
return "permission_denied";
|
|
115
|
+
case -32009:
|
|
116
|
+
return "conflict";
|
|
117
|
+
case -32010:
|
|
118
|
+
return "domain";
|
|
119
|
+
case -32022:
|
|
120
|
+
return "key_reused";
|
|
121
|
+
case -32024:
|
|
122
|
+
return "unknown_outcome";
|
|
123
|
+
case -32029: // an idempotent run in flight, or a limit spent — the problem says which
|
|
124
|
+
return "exhausted";
|
|
125
|
+
default:
|
|
126
|
+
return "internal";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function upperSnake(kind) {
|
|
130
|
+
return kind.toUpperCase();
|
|
131
|
+
}
|
|
132
|
+
/** What the error declares: on itself first, then on its class. */
|
|
133
|
+
function declared(error) {
|
|
134
|
+
const own = error;
|
|
135
|
+
const type = (error.constructor ?? {});
|
|
136
|
+
return {
|
|
137
|
+
...(isKind(own.failureKind ?? type.failureKind)
|
|
138
|
+
? { failureKind: own.failureKind ?? type.failureKind }
|
|
139
|
+
: {}),
|
|
140
|
+
...(typeof (own.reason ?? type.reason) === "string"
|
|
141
|
+
? { reason: own.reason ?? type.reason }
|
|
142
|
+
: {}),
|
|
143
|
+
...(typeof (own.retryAfter ?? type.retryAfter) === "number"
|
|
144
|
+
? { retryAfter: own.retryAfter ?? type.retryAfter }
|
|
145
|
+
: {}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function isProblem(value) {
|
|
149
|
+
return (value !== null &&
|
|
150
|
+
typeof value === "object" &&
|
|
151
|
+
isKind(value.kind) &&
|
|
152
|
+
typeof value.next_step === "string");
|
|
153
|
+
}
|
|
154
|
+
function failure(kind, said, aborted = false) {
|
|
155
|
+
const nextStep = said.nextStep ?? NEXT_STEP_OF[kind];
|
|
156
|
+
const retryable = RETRYABLE_STEPS.has(nextStep);
|
|
157
|
+
return {
|
|
158
|
+
kind,
|
|
159
|
+
reason: said.reason ?? upperSnake(kind),
|
|
160
|
+
nextStep,
|
|
161
|
+
retryable,
|
|
162
|
+
retryAfter: retryable ? (said.retryAfter ?? DEFAULT_RETRY_AFTER) : undefined,
|
|
163
|
+
permanent: !aborted && PERMANENT_KINDS.has(kind),
|
|
164
|
+
fault: !aborted && FAULTS.has(kind),
|
|
165
|
+
aborted,
|
|
166
|
+
problem: said.problem,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The failure `error` is.
|
|
171
|
+
*
|
|
172
|
+
* 1. Whoever asked stopped waiting (`AbortError`): aborted — a write may have run, so its kind is
|
|
173
|
+
* `unknown_outcome`, but it is nobody's failure.
|
|
174
|
+
* 2. What the error declares (`failureKind`, `reason`, `retryAfter`), on itself or its class.
|
|
175
|
+
* 3. What the server said (`problem`, a `Problem` — the same over REST, JSON-RPC and gRPC): its
|
|
176
|
+
* kind, reason, next step and wait.
|
|
177
|
+
* 4. A REST answer (`TransportError`) with a status and no problem: the kind its HTTP status
|
|
178
|
+
* stands for. Another wire's transport declares its kind instead (`kindOfGrpcCode`,
|
|
179
|
+
* `kindOfJsonRpcCode`).
|
|
180
|
+
* 5. A request that got no answer (`status: null`): `unknown_outcome` — it may have run.
|
|
181
|
+
* Final: anything else is `internal`, a fault.
|
|
182
|
+
*/
|
|
183
|
+
export function failureOf(error) {
|
|
184
|
+
if (error === null || typeof error !== "object")
|
|
185
|
+
return failure("internal", {});
|
|
186
|
+
const named = error.name;
|
|
187
|
+
if (named === "AbortError")
|
|
188
|
+
return failure("unknown_outcome", {}, true);
|
|
189
|
+
const own = declared(error);
|
|
190
|
+
if (own.failureKind !== undefined)
|
|
191
|
+
return failure(own.failureKind, {
|
|
192
|
+
...(own.reason !== undefined ? { reason: own.reason } : {}),
|
|
193
|
+
...(own.retryAfter !== undefined ? { retryAfter: own.retryAfter } : {}),
|
|
194
|
+
});
|
|
195
|
+
const answered = error;
|
|
196
|
+
if (isProblem(answered.problem)) {
|
|
197
|
+
const problem = answered.problem;
|
|
198
|
+
return failure(problem.kind, {
|
|
199
|
+
reason: problem.reason,
|
|
200
|
+
nextStep: problem.next_step,
|
|
201
|
+
...(problem.retry_after !== undefined ? { retryAfter: problem.retry_after } : {}),
|
|
202
|
+
problem,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if ("problem" in answered && "status" in answered) {
|
|
206
|
+
if (typeof answered.status === "number")
|
|
207
|
+
return failure(kindOfHttpStatus(answered.status), {});
|
|
208
|
+
if (answered.status === null)
|
|
209
|
+
return failure("unknown_outcome", {});
|
|
210
|
+
}
|
|
211
|
+
return failure("internal", {});
|
|
212
|
+
}
|
package/dist/common/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { collectionOf, type EntityCollection, type HeldCollection, isCollection, isPartial, relatedCriteria, type RelationKeys, totalOf, } from "./collection.js";
|
|
6
6
|
export { Decimal, type DecimalLike } from "./decimal.js";
|
|
7
|
+
export { type DeclaredFailure, type Failure, failureOf, kindOfGrpcCode, kindOfHttpStatus, kindOfJsonRpcCode, PERMANENT_KINDS, } from "./failures.js";
|
|
7
8
|
export { isEntityId, momentOf, newEntityId } from "./ids.js";
|
|
8
9
|
export { Money } from "./money.js";
|
|
9
10
|
export { BYTES, type JsonValue, pack, readValues, type Rebuild, rebuilt, unpack, valuesOf, } from "./serialization.js";
|
package/dist/common/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { collectionOf, isCollection, isPartial, relatedCriteria, totalOf, } from "./collection.js";
|
|
6
6
|
export { Decimal } from "./decimal.js";
|
|
7
|
+
export { failureOf, kindOfGrpcCode, kindOfHttpStatus, kindOfJsonRpcCode, PERMANENT_KINDS, } from "./failures.js";
|
|
7
8
|
export { isEntityId, momentOf, newEntityId } from "./ids.js";
|
|
8
9
|
export { Money } from "./money.js";
|
|
9
10
|
export { BYTES, pack, readValues, rebuilt, unpack, valuesOf, } from "./serialization.js";
|
|
@@ -17,6 +17,18 @@ export type Execution = {
|
|
|
17
17
|
readonly boundedContext: string;
|
|
18
18
|
/** The run that executed this one: an ApplicationService, for its Features. */
|
|
19
19
|
readonly parent: Execution | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The chain it belongs to: the id of what started it all — the root run, or the first event
|
|
22
|
+
* of a flow it continues. Every run and event of one flow share it.
|
|
23
|
+
*/
|
|
24
|
+
readonly correlationId: string;
|
|
25
|
+
/** What directly caused it: its parent run, or the event it executes; none for a root. */
|
|
26
|
+
readonly causationId: string | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* What cancels it: the signal it was executed with, inherited by its Features — and by every
|
|
29
|
+
* request its bound transport sends.
|
|
30
|
+
*/
|
|
31
|
+
readonly signal: AbortSignal | undefined;
|
|
20
32
|
readonly span: Span;
|
|
21
33
|
/** What it runs with, read once where the gesture started and inherited by every child. */
|
|
22
34
|
readonly context: Readonly<Record<string, unknown>>;
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import type { ContextStore, ContextValues } from "../domain/store.js";
|
|
2
2
|
/**
|
|
3
3
|
* One level of the context: the process's (`processContext`) or a bounded context's
|
|
4
|
-
* (`
|
|
4
|
+
* (`projectBoundedContext.context`), read and written the same way on every platform.
|
|
5
5
|
*
|
|
6
6
|
* processContext.set({ tenant_id: "acme" }); the company changed — every screen hears it
|
|
7
7
|
* processContext.get("tenant_id") → "acme"
|
|
8
|
-
*
|
|
8
|
+
* projectBoundedContext.context.set({ warehouse_id: 4 }); only what it runs
|
|
9
9
|
* const stop = processContext.watch(() => redraw());
|
|
10
10
|
*
|
|
11
11
|
* Context: sincpro_framework reads its context from a tree — the process, the bounded context,
|
|
12
12
|
* the scope, the execution — and the nearest wins. A client has the same levels: a use case runs
|
|
13
13
|
* with the process's values, under them its bounded context's, under them a scope's
|
|
14
|
-
* (`
|
|
15
|
-
* it runs. That snapshot is `this.context` and what travels to the server in
|
|
14
|
+
* (`projectBoundedContext.scoped({...})`), read once where the gesture starts and inherited by
|
|
15
|
+
* every Feature it runs. That snapshot is `this.context` and what travels to the server in
|
|
16
|
+
* `sincpro-context`.
|
|
16
17
|
*
|
|
17
18
|
* Where the values live is the platform's (`ContextStore`): this is only the door. `values` is
|
|
18
19
|
* the same object until something changes it, so a screen can hand it straight to React's
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { Command, DomainEvent, Executable, ResponseClass } from "./dto.js";
|
|
2
|
+
import type { Cause, ExecuteOptions } from "./pipeline.js";
|
|
3
|
+
/** What the second argument of `execute` is: options, a delivered event's cause, or a type. */
|
|
4
|
+
export declare function optionsOf(second: unknown): ExecuteOptions;
|
|
5
|
+
/**
|
|
6
|
+
* What executes a Command or a domain event: the bounded context, a scope of it, an
|
|
7
|
+
* ApplicationService's features. A Command answers its Response; an event, whatever its
|
|
8
|
+
* subscriber returns.
|
|
9
|
+
*/
|
|
3
10
|
export interface CommandBus {
|
|
4
11
|
execute<R>(command: Command<R>, returnType?: ResponseClass<NoInfer<R>>): Promise<R>;
|
|
12
|
+
execute<R>(command: Command<R>, options: ExecuteOptions): Promise<R>;
|
|
13
|
+
execute(event: DomainEvent, options?: ExecuteOptions | Cause): Promise<unknown>;
|
|
5
14
|
}
|
|
6
|
-
/** @internal — how a bus hands
|
|
7
|
-
export type Dispatch = (
|
|
8
|
-
/** @internal — the heterogeneous registry erases the type; registration checks the association. */
|
|
9
|
-
export declare function executeTyped<R>(dispatch: Dispatch, command: Command<R>): Promise<R>;
|
|
15
|
+
/** @internal — how a bus hands what it executes to its generation. */
|
|
16
|
+
export type Dispatch = (executable: Executable, options: ExecuteOptions) => Promise<unknown>;
|
|
10
17
|
/**
|
|
11
18
|
* What an ApplicationService composes its context's Features through (`this.featureBus`): the
|
|
12
19
|
* same pipeline, as a child of the running execution, and Features only.
|
|
@@ -14,5 +21,7 @@ export declare function executeTyped<R>(dispatch: Dispatch, command: Command<R>)
|
|
|
14
21
|
export declare class FeatureBus implements CommandBus {
|
|
15
22
|
private readonly dispatch;
|
|
16
23
|
constructor(dispatch: Dispatch);
|
|
17
|
-
execute<R>(command: Command<R>,
|
|
24
|
+
execute<R>(command: Command<R>, returnType?: ResponseClass<NoInfer<R>>): Promise<R>;
|
|
25
|
+
execute<R>(command: Command<R>, options: ExecuteOptions): Promise<R>;
|
|
26
|
+
execute(event: DomainEvent, options?: ExecuteOptions | Cause): Promise<unknown>;
|
|
18
27
|
}
|
package/dist/core/domain/bus.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/** What the second argument of `execute` is: options, a delivered event's cause, or a type. */
|
|
2
|
+
export function optionsOf(second) {
|
|
3
|
+
if (second === null || typeof second !== "object")
|
|
4
|
+
return {};
|
|
5
|
+
const given = second;
|
|
6
|
+
if (typeof given.id === "string" && typeof given.correlationId === "string")
|
|
7
|
+
return { cause: { id: given.id, correlationId: given.correlationId } };
|
|
8
|
+
return {
|
|
9
|
+
...(given.signal ? { signal: given.signal } : {}),
|
|
10
|
+
...(given.cause ? { cause: given.cause } : {}),
|
|
11
|
+
};
|
|
5
12
|
}
|
|
6
13
|
/**
|
|
7
14
|
* What an ApplicationService composes its context's Features through (`this.featureBus`): the
|
|
@@ -12,7 +19,8 @@ export class FeatureBus {
|
|
|
12
19
|
constructor(dispatch) {
|
|
13
20
|
this.dispatch = dispatch;
|
|
14
21
|
}
|
|
15
|
-
execute(
|
|
16
|
-
|
|
22
|
+
execute(executable, second) {
|
|
23
|
+
// As with Python's return_type, the answer is not validated at runtime.
|
|
24
|
+
return this.dispatch(executable, optionsOf(second));
|
|
17
25
|
}
|
|
18
26
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* class CommandCancelIssue extends Command<ResponseCancelIssue> {
|
|
6
6
|
* constructor(readonly issue_id: string) { super(); }
|
|
7
7
|
* }
|
|
8
|
-
* const answer = await
|
|
8
|
+
* const answer = await projectBoundedContext.execute(new CommandCancelIssue("x")); // ResponseCancelIssue
|
|
9
9
|
*
|
|
10
10
|
* Context: the backend asks `bus(dto, ResponseType)` for the type; here the Command carries it, so
|
|
11
11
|
* `execute` infers it with no second argument. The association is type-only: absent from a
|
|
@@ -22,11 +22,41 @@ export declare abstract class Command<R> extends DataTransferObject {
|
|
|
22
22
|
/** Type-only association: absent from serialized DTOs and not a runtime validator. */
|
|
23
23
|
readonly [responseType]: R;
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* A fact that already happened, as sincpro_framework's `DomainEvent`: executed like a Command, by
|
|
27
|
+
* the Feature or ApplicationService registered for its class — its subscriber in this bounded
|
|
28
|
+
* context —, through the same pipeline. Delivering it (a queue, an outbox) is the platform's.
|
|
29
|
+
*
|
|
30
|
+
* class OrderPaid extends DomainEvent {
|
|
31
|
+
* static readonly eventName = "shop.order.v1.paid";
|
|
32
|
+
* constructor(readonly order_id: string) { super(); }
|
|
33
|
+
* }
|
|
34
|
+
* projectBoundedContext.feature(OrderPaid)(NotifyCustomer);
|
|
35
|
+
* await projectBoundedContext.execute(new OrderPaid("o-1"));
|
|
36
|
+
*
|
|
37
|
+
* Context: the backend names an event on the wire by `name`, `<context>.<aggregate>.vN.<fact>`,
|
|
38
|
+
* and defaults it to the class's name; a client is minified, so the name is declared, as
|
|
39
|
+
* `serviceName` is. `name` is reserved, as the backend reserves it: every event reads its class's
|
|
40
|
+
* `eventName` there, and a subclass cannot make it a field.
|
|
41
|
+
*/
|
|
42
|
+
export declare abstract class DomainEvent extends DataTransferObject {
|
|
43
|
+
protected readonly eventBrand: void;
|
|
44
|
+
/** Its wire name: its class's `eventName`. */
|
|
45
|
+
get name(): string;
|
|
46
|
+
}
|
|
47
|
+
/** What an event class declares: the name it is known by on the wire. */
|
|
48
|
+
type EventNamed = {
|
|
49
|
+
readonly eventName: string;
|
|
50
|
+
};
|
|
25
51
|
export type AnyCommand = Command<unknown>;
|
|
26
|
-
/** What a Command
|
|
27
|
-
export type
|
|
52
|
+
/** What a service is registered for and executes: a Command, or a domain event. */
|
|
53
|
+
export type Executable = AnyCommand | DomainEvent;
|
|
54
|
+
/** What it answers: a Command's Response; whatever its subscriber returns, for an event. */
|
|
55
|
+
export type Answer<T> = T extends Command<infer R> ? R : unknown;
|
|
28
56
|
/** A Command's class: what a service is registered for, by exact constructor identity. */
|
|
29
57
|
export type CommandClass<C extends AnyCommand> = abstract new (...args: never[]) => C;
|
|
58
|
+
/** A domain event's class, with its wire name. */
|
|
59
|
+
export type EventClass<E extends DomainEvent> = (abstract new (...args: never[]) => E) & EventNamed;
|
|
30
60
|
/** A Response's class: the backend's `return_type`, accepted for the same reading. */
|
|
31
61
|
export type ResponseClass<R> = abstract new (...args: never[]) => R;
|
|
32
62
|
export {};
|
package/dist/core/domain/dto.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* class CommandCancelIssue extends Command<ResponseCancelIssue> {
|
|
6
6
|
* constructor(readonly issue_id: string) { super(); }
|
|
7
7
|
* }
|
|
8
|
-
* const answer = await
|
|
8
|
+
* const answer = await projectBoundedContext.execute(new CommandCancelIssue("x")); // ResponseCancelIssue
|
|
9
9
|
*
|
|
10
10
|
* Context: the backend asks `bus(dto, ResponseType)` for the type; here the Command carries it, so
|
|
11
11
|
* `execute` infers it with no second argument. The association is type-only: absent from a
|
|
@@ -18,3 +18,26 @@ export class Response extends DataTransferObject {
|
|
|
18
18
|
}
|
|
19
19
|
export class Command extends DataTransferObject {
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A fact that already happened, as sincpro_framework's `DomainEvent`: executed like a Command, by
|
|
23
|
+
* the Feature or ApplicationService registered for its class — its subscriber in this bounded
|
|
24
|
+
* context —, through the same pipeline. Delivering it (a queue, an outbox) is the platform's.
|
|
25
|
+
*
|
|
26
|
+
* class OrderPaid extends DomainEvent {
|
|
27
|
+
* static readonly eventName = "shop.order.v1.paid";
|
|
28
|
+
* constructor(readonly order_id: string) { super(); }
|
|
29
|
+
* }
|
|
30
|
+
* projectBoundedContext.feature(OrderPaid)(NotifyCustomer);
|
|
31
|
+
* await projectBoundedContext.execute(new OrderPaid("o-1"));
|
|
32
|
+
*
|
|
33
|
+
* Context: the backend names an event on the wire by `name`, `<context>.<aggregate>.vN.<fact>`,
|
|
34
|
+
* and defaults it to the class's name; a client is minified, so the name is declared, as
|
|
35
|
+
* `serviceName` is. `name` is reserved, as the backend reserves it: every event reads its class's
|
|
36
|
+
* `eventName` there, and a subclass cannot make it a field.
|
|
37
|
+
*/
|
|
38
|
+
export class DomainEvent extends DataTransferObject {
|
|
39
|
+
/** Its wire name: its class's `eventName`. */
|
|
40
|
+
get name() {
|
|
41
|
+
return this.constructor.eventName ?? "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What answers a use case's failure instead of raising it — sincpro_framework's error handlers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* projectBoundedContext.addFeatureErrorHandler((error) => {
|
|
5
5
|
* if (error instanceof IssueNotFound) return new ResponseEmpty(); // the answer
|
|
6
6
|
* throw error; // the next handler
|
|
7
7
|
* });
|
|
8
8
|
*
|
|
9
|
-
* Context: three chains per bounded context — global (around `
|
|
9
|
+
* Context: three chains per bounded context — global (around `projectBoundedContext.execute`), feature and
|
|
10
10
|
* application service —, each run first-registered-first. A handler that throws hands what it
|
|
11
11
|
* threw to the next; the last one's throw reaches the caller. The failure was already recorded
|
|
12
12
|
* where it happened (its span, its log line and, when unexpected, its report): a handler decides
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Execution } from "../../context/domain/execution.js";
|
|
2
|
+
import type { Executable } from "./dto.js";
|
|
3
|
+
import type { LayerLogger } from "../../observability/domain/logging.js";
|
|
4
|
+
import type { Outcome } from "../../observability/domain/tracing.js";
|
|
5
|
+
/**
|
|
6
|
+
* Where the execution pipeline is extended — as sincpro_framework's `bus_pipeline`, in two kinds:
|
|
7
|
+
*
|
|
8
|
+
* control interceptors wrap the service: look, veto, adjust or answer
|
|
9
|
+
* observation listeners a run started, answered, or a failure escaped: heard, never changed
|
|
10
|
+
*
|
|
11
|
+
* projectBoundedContext.interceptor(CommandPay)(async (command, next, run) => {
|
|
12
|
+
* if (!credit.allows(command.customer_id)) throw new NoCredit(); // veto
|
|
13
|
+
* return next(command); // the next, or the service
|
|
14
|
+
* });
|
|
15
|
+
* projectBoundedContext.onCompletion((done) => activity.succeeded(done.execution.useCase));
|
|
16
|
+
*
|
|
17
|
+
* Context: the core owns the flow — context, span, log lines, outcome, report, handlers — and
|
|
18
|
+
* these are the places an application changes it from outside, without touching a service. An
|
|
19
|
+
* interceptor runs inside the run: its veto is the use case's failure, observed and answered by
|
|
20
|
+
* the error handlers like any other. A listener that fails is ignored: hearing never changes
|
|
21
|
+
* the answer.
|
|
22
|
+
*/
|
|
23
|
+
export type PipelineLayer = "feature" | "application_service";
|
|
24
|
+
/** What caused a run from outside: an event delivered with its envelope continues its chain. */
|
|
25
|
+
export type Cause = {
|
|
26
|
+
/** The id of what caused it — a delivered event's. */
|
|
27
|
+
readonly id: string;
|
|
28
|
+
/** The chain it continues. */
|
|
29
|
+
readonly correlationId: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* How a Command or an event is executed: what cancels it, and — for a delivered event — what
|
|
33
|
+
* caused it.
|
|
34
|
+
*
|
|
35
|
+
* await salesBoundedContext.execute(new CommandQuote(3), { signal: screen.signal });
|
|
36
|
+
*
|
|
37
|
+
* Context: a screen that closes, a navigation that leaves, a user who gives up stop waiting; the
|
|
38
|
+
* run ends `aborted` (not reported), its Features inherit the signal, and the requests its
|
|
39
|
+
* transport sends are cancelled with it.
|
|
40
|
+
*/
|
|
41
|
+
export type ExecuteOptions = {
|
|
42
|
+
readonly signal?: AbortSignal;
|
|
43
|
+
readonly cause?: Cause;
|
|
44
|
+
};
|
|
45
|
+
/** The run an interceptor wraps: who runs, inside which execution, writing where. */
|
|
46
|
+
export type InterceptedRun = {
|
|
47
|
+
readonly boundedContext: string;
|
|
48
|
+
/** `<bounded context>.<serviceName>`. */
|
|
49
|
+
readonly useCase: string;
|
|
50
|
+
readonly layer: PipelineLayer;
|
|
51
|
+
/** Its id, span, context and chain. */
|
|
52
|
+
readonly execution: Execution;
|
|
53
|
+
/** The run's logger, bound to it. */
|
|
54
|
+
readonly logger: LayerLogger;
|
|
55
|
+
};
|
|
56
|
+
/** The rest of the chain: the next interceptor, or the service. */
|
|
57
|
+
export type CallNext<T extends Executable> = (executable: T) => Promise<unknown>;
|
|
58
|
+
/**
|
|
59
|
+
* Runs around the service of a Command or an event. It passes on what it received — or another
|
|
60
|
+
* of the same class: the bus routes by class, so another class would be another use case — and
|
|
61
|
+
* returns what the next answered, or an answer of its own; it throws to veto.
|
|
62
|
+
*/
|
|
63
|
+
export type Interceptor<T extends Executable = Executable> = (executable: T, next: CallNext<T>, run: InterceptedRun) => unknown;
|
|
64
|
+
/** A run began. */
|
|
65
|
+
export type ExecutionStarted = {
|
|
66
|
+
readonly executable: Executable;
|
|
67
|
+
readonly execution: Execution;
|
|
68
|
+
readonly layer: PipelineLayer;
|
|
69
|
+
};
|
|
70
|
+
/** A run's service answered. */
|
|
71
|
+
export type ExecutionCompleted = ExecutionStarted & {
|
|
72
|
+
readonly answer: unknown;
|
|
73
|
+
readonly durationMs: number;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* A failure escaped a call — after every handler —, said once, where it first failed. No
|
|
77
|
+
* execution when it reached none: nothing is registered for the class.
|
|
78
|
+
*/
|
|
79
|
+
export type ExecutionFailed = {
|
|
80
|
+
readonly executable: Executable;
|
|
81
|
+
readonly execution: Execution | undefined;
|
|
82
|
+
readonly layer: PipelineLayer | undefined;
|
|
83
|
+
readonly error: unknown;
|
|
84
|
+
readonly outcome: Outcome;
|
|
85
|
+
};
|
|
86
|
+
/** Who hears a moment of the pipeline. */
|
|
87
|
+
export type Listener<E> = (heard: E) => void;
|
|
88
|
+
/** A component wired into a bounded context: its interceptors, listeners, services. */
|
|
89
|
+
export type Extension<B> = (context: B) => void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|