@classytic/primitives 0.28.0 → 0.30.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/CHANGELOG.md +1217 -1119
- package/dist/approval.d.mts +98 -6
- package/dist/approval.mjs +61 -4
- package/dist/canonical.d.mts +6 -1
- package/dist/canonical.mjs +10 -4
- package/dist/durable-events.d.mts +83 -0
- package/dist/durable-events.mjs +115 -0
- package/dist/environment.d.mts +30 -1
- package/dist/environment.mjs +72 -1
- package/dist/events.d.mts +45 -7
- package/dist/events.mjs +174 -7
- package/dist/idempotency.d.mts +4 -0
- package/dist/idempotency.mjs +4 -0
- package/dist/item-stock.d.mts +43 -0
- package/dist/item-stock.mjs +67 -0
- package/dist/legal-form.d.mts +12 -0
- package/dist/legal-form.mjs +27 -0
- package/dist/line-settlement.d.mts +70 -0
- package/dist/line-settlement.mjs +79 -0
- package/dist/memory-outbox.d.mts +1 -0
- package/dist/memory-outbox.mjs +18 -2
- package/dist/monetization.d.mts +3 -1
- package/dist/monetization.mjs +7 -1
- package/dist/outbox.d.mts +46 -5
- package/dist/outbox.mjs +43 -1
- package/dist/payment-events.d.mts +6 -0
- package/dist/period.d.mts +22 -1
- package/dist/period.mjs +30 -1
- package/dist/phone.d.mts +13 -1
- package/dist/phone.mjs +21 -1
- package/dist/proration.d.mts +11 -1
- package/dist/proration.mjs +24 -1
- package/dist/signed-token.d.mts +54 -0
- package/dist/signed-token.mjs +139 -0
- package/dist/split-allocation.d.mts +20 -2
- package/dist/split-allocation.mjs +57 -1
- package/package.json +364 -285
package/dist/approval.d.mts
CHANGED
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
* isApproved(chain); // → true
|
|
33
33
|
*/
|
|
34
34
|
type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'skipped';
|
|
35
|
-
|
|
35
|
+
declare const CHAIN_ORDERS: readonly ["sequential", "parallel"];
|
|
36
|
+
type ChainOrder = (typeof CHAIN_ORDERS)[number];
|
|
37
|
+
declare const APPROVAL_THRESHOLD_OPS: readonly ["gt", "gte", "lt", "lte", "eq"];
|
|
36
38
|
interface Approver {
|
|
37
39
|
readonly id: string;
|
|
38
40
|
readonly name?: string;
|
|
@@ -43,6 +45,11 @@ interface ApprovalDecision {
|
|
|
43
45
|
readonly decision: 'approved' | 'rejected';
|
|
44
46
|
readonly note?: string;
|
|
45
47
|
readonly decidedAt: Date;
|
|
48
|
+
/**
|
|
49
|
+
* The org the approver acted from, when it differs from the document's (a cross-branch
|
|
50
|
+
* decision). The document's own org is the document's tenant field, so both are on the row.
|
|
51
|
+
*/
|
|
52
|
+
readonly actorOrganizationId?: string;
|
|
46
53
|
}
|
|
47
54
|
/**
|
|
48
55
|
* Optional threshold gate. The primitive does not evaluate this — it's
|
|
@@ -52,7 +59,7 @@ interface ApprovalDecision {
|
|
|
52
59
|
*/
|
|
53
60
|
interface ApprovalThreshold {
|
|
54
61
|
readonly field: string;
|
|
55
|
-
readonly op:
|
|
62
|
+
readonly op: (typeof APPROVAL_THRESHOLD_OPS)[number];
|
|
56
63
|
readonly value: number;
|
|
57
64
|
}
|
|
58
65
|
interface ApprovalStep {
|
|
@@ -79,6 +86,22 @@ interface ApprovalChain {
|
|
|
79
86
|
* {@link isNotRequired} tests the structure instead, which no schema can drop.
|
|
80
87
|
*/
|
|
81
88
|
readonly notRequiredReason?: string;
|
|
89
|
+
/** Who asked, when, and against what — set by {@link attachRequest}. The requester may not approve it. */
|
|
90
|
+
readonly request?: ApprovalRequest;
|
|
91
|
+
}
|
|
92
|
+
/** The maker of an approval request, and the basis it was approved against. */
|
|
93
|
+
interface ApprovalRequest {
|
|
94
|
+
readonly by: string;
|
|
95
|
+
readonly at: Date;
|
|
96
|
+
/**
|
|
97
|
+
* What the approval covers. A later amount above `basis.amount` is not covered. `unit` states the
|
|
98
|
+
* amount is integer base-currency minor units; a stored amount without it is of unknown unit.
|
|
99
|
+
*/
|
|
100
|
+
readonly basis?: {
|
|
101
|
+
readonly amount?: number;
|
|
102
|
+
readonly unit?: 'minor';
|
|
103
|
+
readonly category?: string;
|
|
104
|
+
};
|
|
82
105
|
}
|
|
83
106
|
interface CreateChainInput {
|
|
84
107
|
readonly order: ChainOrder;
|
|
@@ -101,7 +124,11 @@ type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS'
|
|
|
101
124
|
* `CHAIN_INCOMPLETE`: the chain is not unfinished, it is absent — the
|
|
102
125
|
* document never carried one, or a schema that never declared the field
|
|
103
126
|
* stripped it on write. See {@link assertApproved}. */
|
|
104
|
-
'CHAIN_MISSING'
|
|
127
|
+
'CHAIN_MISSING' |
|
|
128
|
+
/** The requester tried to approve their own request (maker-checker). */
|
|
129
|
+
'SELF_APPROVAL' |
|
|
130
|
+
/** Removing the requester left a step with fewer approvers than its quorum. */
|
|
131
|
+
'NO_INDEPENDENT_APPROVER';
|
|
105
132
|
declare class ApprovalError extends Error {
|
|
106
133
|
readonly name = "ApprovalError";
|
|
107
134
|
readonly code: ApprovalErrorCode;
|
|
@@ -145,6 +172,14 @@ declare function createChain(input: CreateChainInput): ApprovalChain;
|
|
|
145
172
|
* @param reason Why approval was not required, for the audit trail.
|
|
146
173
|
*/
|
|
147
174
|
declare function notRequiredChain(reason: string): ApprovalChain;
|
|
175
|
+
/**
|
|
176
|
+
* Stamp the requester onto a chain and remove them from every step's approvers
|
|
177
|
+
* (maker-checker). A zero-step chain gets the request and nothing else.
|
|
178
|
+
*
|
|
179
|
+
* @throws ApprovalError `NO_INDEPENDENT_APPROVER` when a step is left with
|
|
180
|
+
* fewer approvers than `requiredApprovals` — nobody else can approve it.
|
|
181
|
+
*/
|
|
182
|
+
declare function attachRequest(chain: ApprovalChain, request: ApprovalRequest): ApprovalChain;
|
|
148
183
|
/**
|
|
149
184
|
* Returns the next step awaiting a decision — or `null` if none.
|
|
150
185
|
*
|
|
@@ -154,15 +189,72 @@ declare function notRequiredChain(reason: string): ApprovalChain;
|
|
|
154
189
|
* For `parallel` chains: returns the first pending step (all pending steps
|
|
155
190
|
* are active simultaneously; host typically iterates with {@link pendingSteps}).
|
|
156
191
|
*/
|
|
157
|
-
declare function nextPendingStep
|
|
192
|
+
declare function nextPendingStep<S extends {
|
|
193
|
+
readonly status: ApprovalStatus;
|
|
194
|
+
}>(chain: {
|
|
195
|
+
readonly steps: readonly S[];
|
|
196
|
+
}): S | null;
|
|
158
197
|
/** All steps currently eligible to receive a decision. */
|
|
159
|
-
declare function pendingSteps
|
|
198
|
+
declare function pendingSteps<S extends {
|
|
199
|
+
readonly status: ApprovalStatus;
|
|
200
|
+
}>(chain: {
|
|
201
|
+
readonly order: ChainOrder;
|
|
202
|
+
readonly steps: readonly S[];
|
|
203
|
+
}): readonly S[];
|
|
204
|
+
/**
|
|
205
|
+
* The verbs every approval-bearing resource mounts at `POST /:id/action`.
|
|
206
|
+
* Servers (arc-approval) and clients (SDK, UI) both read these; never write the strings.
|
|
207
|
+
*/
|
|
208
|
+
declare const APPROVAL_ACTIONS: {
|
|
209
|
+
readonly submit: "submit_for_approval";
|
|
210
|
+
readonly decide: "decide";
|
|
211
|
+
};
|
|
212
|
+
type ApprovalActionName = (typeof APPROVAL_ACTIONS)[keyof typeof APPROVAL_ACTIONS];
|
|
213
|
+
/** Where a submit sources its chain: inline `chain`, the policy matrix, or inferred. */
|
|
214
|
+
declare const APPROVAL_CHAIN_SOURCES: readonly ["auto", "literal", "policy"];
|
|
215
|
+
type ApprovalChainSource = (typeof APPROVAL_CHAIN_SOURCES)[number];
|
|
216
|
+
declare const APPROVAL_DECISIONS: readonly ["approved", "rejected"];
|
|
217
|
+
type ApprovalDecisionKind = (typeof APPROVAL_DECISIONS)[number];
|
|
218
|
+
/** Longest decision note the server accepts. */
|
|
219
|
+
declare const APPROVAL_NOTE_MAX_LENGTH = 1000;
|
|
220
|
+
/** `submit_for_approval` body. */
|
|
221
|
+
type ApprovalSubmitBody = {
|
|
222
|
+
readonly source?: ApprovalChainSource;
|
|
223
|
+
readonly chain?: CreateChainInput;
|
|
224
|
+
};
|
|
225
|
+
/** `decide` body. The approver is the authenticated actor — never a body field. */
|
|
226
|
+
type ApprovalDecideBody = {
|
|
227
|
+
readonly stepId: string;
|
|
228
|
+
readonly decision: ApprovalDecisionKind;
|
|
229
|
+
readonly note?: string;
|
|
230
|
+
};
|
|
231
|
+
/** The chain fields {@link decidableStepIds} reads — satisfied by a stored chain and by its JSON form. */
|
|
232
|
+
interface DecidableChain {
|
|
233
|
+
readonly order: ChainOrder;
|
|
234
|
+
readonly status: ApprovalStatus;
|
|
235
|
+
readonly steps: readonly {
|
|
236
|
+
readonly id: string;
|
|
237
|
+
readonly status: ApprovalStatus;
|
|
238
|
+
readonly approvers: readonly {
|
|
239
|
+
readonly id: string;
|
|
240
|
+
}[];
|
|
241
|
+
readonly decisions: readonly {
|
|
242
|
+
readonly approverId: string;
|
|
243
|
+
}[];
|
|
244
|
+
}[];
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The steps `actorId` may decide now: active, naming the actor as an approver, not yet
|
|
248
|
+
* decided by them. The ONE answer to "may this actor decide" for server and UI alike.
|
|
249
|
+
*/
|
|
250
|
+
declare function decidableStepIds(chain: DecidableChain, actorId: string | null | undefined): string[];
|
|
160
251
|
interface DecisionInput {
|
|
161
252
|
readonly stepId: string;
|
|
162
253
|
readonly approverId: string;
|
|
163
254
|
readonly decision: 'approved' | 'rejected';
|
|
164
255
|
readonly note?: string;
|
|
165
256
|
readonly decidedAt?: Date;
|
|
257
|
+
readonly actorOrganizationId?: string;
|
|
166
258
|
}
|
|
167
259
|
/**
|
|
168
260
|
* Apply a decision to a step. Returns a new chain — original is not mutated.
|
|
@@ -242,4 +334,4 @@ declare function isPending(chain: ApprovalChain): boolean;
|
|
|
242
334
|
*/
|
|
243
335
|
declare function decisionCount(chain: ApprovalChain): number;
|
|
244
336
|
//#endregion
|
|
245
|
-
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, AssertApprovedOptions, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
|
337
|
+
export { APPROVAL_ACTIONS, APPROVAL_CHAIN_SOURCES, APPROVAL_DECISIONS, APPROVAL_NOTE_MAX_LENGTH, APPROVAL_THRESHOLD_OPS, ApprovalActionName, ApprovalChain, ApprovalChainSource, ApprovalDecideBody, ApprovalDecision, ApprovalDecisionKind, ApprovalError, ApprovalErrorCode, ApprovalRequest, ApprovalStatus, ApprovalStep, ApprovalSubmitBody, ApprovalThreshold, Approver, AssertApprovedOptions, CHAIN_ORDERS, ChainOrder, CreateChainInput, CreateStepInput, DecidableChain, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decidableStepIds, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
package/dist/approval.mjs
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
//#region src/workflow/approval.ts
|
|
2
|
+
const CHAIN_ORDERS = ["sequential", "parallel"];
|
|
3
|
+
const APPROVAL_THRESHOLD_OPS = [
|
|
4
|
+
"gt",
|
|
5
|
+
"gte",
|
|
6
|
+
"lt",
|
|
7
|
+
"lte",
|
|
8
|
+
"eq"
|
|
9
|
+
];
|
|
2
10
|
var ApprovalError = class extends Error {
|
|
3
11
|
name = "ApprovalError";
|
|
4
12
|
code;
|
|
@@ -82,6 +90,28 @@ function notRequiredChain(reason) {
|
|
|
82
90
|
};
|
|
83
91
|
}
|
|
84
92
|
/**
|
|
93
|
+
* Stamp the requester onto a chain and remove them from every step's approvers
|
|
94
|
+
* (maker-checker). A zero-step chain gets the request and nothing else.
|
|
95
|
+
*
|
|
96
|
+
* @throws ApprovalError `NO_INDEPENDENT_APPROVER` when a step is left with
|
|
97
|
+
* fewer approvers than `requiredApprovals` — nobody else can approve it.
|
|
98
|
+
*/
|
|
99
|
+
function attachRequest(chain, request) {
|
|
100
|
+
const steps = chain.steps.map((step) => {
|
|
101
|
+
const approvers = step.approvers.filter((a) => a.id !== request.by);
|
|
102
|
+
if (step.status === "pending" && approvers.length < step.requiredApprovals) throw new ApprovalError("NO_INDEPENDENT_APPROVER", `step ${step.id} needs ${step.requiredApprovals} approver(s) other than the requester ${request.by}; it has ${approvers.length}`);
|
|
103
|
+
return approvers.length === step.approvers.length ? step : {
|
|
104
|
+
...step,
|
|
105
|
+
approvers
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
...chain,
|
|
110
|
+
steps,
|
|
111
|
+
request
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
85
115
|
* Returns the next step awaiting a decision — or `null` if none.
|
|
86
116
|
*
|
|
87
117
|
* For `sequential` chains: the first `pending` step in order; later steps
|
|
@@ -103,6 +133,31 @@ function pendingSteps(chain) {
|
|
|
103
133
|
return chain.steps.filter((s) => s.status === "pending");
|
|
104
134
|
}
|
|
105
135
|
/**
|
|
136
|
+
* The verbs every approval-bearing resource mounts at `POST /:id/action`.
|
|
137
|
+
* Servers (arc-approval) and clients (SDK, UI) both read these; never write the strings.
|
|
138
|
+
*/
|
|
139
|
+
const APPROVAL_ACTIONS = {
|
|
140
|
+
submit: "submit_for_approval",
|
|
141
|
+
decide: "decide"
|
|
142
|
+
};
|
|
143
|
+
/** Where a submit sources its chain: inline `chain`, the policy matrix, or inferred. */
|
|
144
|
+
const APPROVAL_CHAIN_SOURCES = [
|
|
145
|
+
"auto",
|
|
146
|
+
"literal",
|
|
147
|
+
"policy"
|
|
148
|
+
];
|
|
149
|
+
const APPROVAL_DECISIONS = ["approved", "rejected"];
|
|
150
|
+
/** Longest decision note the server accepts. */
|
|
151
|
+
const APPROVAL_NOTE_MAX_LENGTH = 1e3;
|
|
152
|
+
/**
|
|
153
|
+
* The steps `actorId` may decide now: active, naming the actor as an approver, not yet
|
|
154
|
+
* decided by them. The ONE answer to "may this actor decide" for server and UI alike.
|
|
155
|
+
*/
|
|
156
|
+
function decidableStepIds(chain, actorId) {
|
|
157
|
+
if (!actorId || chain.status !== "pending") return [];
|
|
158
|
+
return pendingSteps(chain).filter((s) => s.approvers.some((a) => a.id === actorId) && !s.decisions.some((d) => d.approverId === actorId)).map((s) => s.id);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
106
161
|
* Apply a decision to a step. Returns a new chain — original is not mutated.
|
|
107
162
|
* Throws if the step is unknown, not active, the approver isn't listed, or
|
|
108
163
|
* the approver has already decided.
|
|
@@ -113,13 +168,15 @@ function applyDecision(chain, input) {
|
|
|
113
168
|
const step = chain.steps[stepIndex];
|
|
114
169
|
if (!step) throw new ApprovalError("UNKNOWN_STEP", `no step with id ${input.stepId}`);
|
|
115
170
|
if (!pendingSteps(chain).some((s) => s.id === input.stepId)) throw new ApprovalError("STEP_NOT_ACTIVE", `step ${input.stepId} is not currently active (status=${step.status})`);
|
|
171
|
+
if (input.decision === "approved" && chain.request && chain.request.by === input.approverId) throw new ApprovalError("SELF_APPROVAL", `approver ${input.approverId} requested this approval and may not approve it`);
|
|
116
172
|
if (!step.approvers.some((a) => a.id === input.approverId)) throw new ApprovalError("UNAUTHORIZED_APPROVER", `approver ${input.approverId} is not listed on step ${input.stepId}`);
|
|
117
173
|
if (step.decisions.some((d) => d.approverId === input.approverId)) throw new ApprovalError("STEP_ALREADY_DECIDED_BY_APPROVER", `approver ${input.approverId} has already decided on step ${input.stepId}`);
|
|
118
174
|
const decision = {
|
|
119
175
|
approverId: input.approverId,
|
|
120
176
|
decision: input.decision,
|
|
121
177
|
...input.note !== void 0 ? { note: input.note } : {},
|
|
122
|
-
decidedAt: input.decidedAt ?? /* @__PURE__ */ new Date()
|
|
178
|
+
decidedAt: input.decidedAt ?? /* @__PURE__ */ new Date(),
|
|
179
|
+
...input.actorOrganizationId !== void 0 ? { actorOrganizationId: input.actorOrganizationId } : {}
|
|
123
180
|
};
|
|
124
181
|
const updatedStep = computeStepStatus({
|
|
125
182
|
...step,
|
|
@@ -127,7 +184,7 @@ function applyDecision(chain, input) {
|
|
|
127
184
|
});
|
|
128
185
|
const updatedSteps = chain.steps.map((s, i) => i === stepIndex ? updatedStep : s);
|
|
129
186
|
return {
|
|
130
|
-
|
|
187
|
+
...chain,
|
|
131
188
|
steps: updatedSteps,
|
|
132
189
|
status: computeChainStatus(updatedSteps)
|
|
133
190
|
};
|
|
@@ -150,7 +207,7 @@ function skipStep(chain, stepId, reason) {
|
|
|
150
207
|
};
|
|
151
208
|
const updatedSteps = chain.steps.map((s, i) => i === stepIndex ? updatedStep : s);
|
|
152
209
|
return {
|
|
153
|
-
|
|
210
|
+
...chain,
|
|
154
211
|
steps: updatedSteps,
|
|
155
212
|
status: computeChainStatus(updatedSteps)
|
|
156
213
|
};
|
|
@@ -248,4 +305,4 @@ function computeChainStatus(steps) {
|
|
|
248
305
|
return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
|
|
249
306
|
}
|
|
250
307
|
//#endregion
|
|
251
|
-
export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
|
308
|
+
export { APPROVAL_ACTIONS, APPROVAL_CHAIN_SOURCES, APPROVAL_DECISIONS, APPROVAL_NOTE_MAX_LENGTH, APPROVAL_THRESHOLD_OPS, ApprovalError, CHAIN_ORDERS, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decidableStepIds, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
package/dist/canonical.d.mts
CHANGED
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
declare class CanonicalizeError extends Error {
|
|
28
28
|
constructor(message: string);
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Deepest nesting canonicalized — MongoDB's own document limit. Deeper input is refused
|
|
32
|
+
* rather than recursed: it may be hostile (a request body), and recursion would blow the stack.
|
|
33
|
+
*/
|
|
34
|
+
declare const MAX_CANONICAL_DEPTH = 100;
|
|
30
35
|
/**
|
|
31
36
|
* Strict canonical JSON string of any value. Deterministic (sorted keys),
|
|
32
37
|
* `Date`-explicit; throws {@link CanonicalizeError} on unsupported/ambiguous
|
|
@@ -38,4 +43,4 @@ declare function sha256Hex(input: string): string;
|
|
|
38
43
|
/** Convenience: `sha256Hex(canonicalJson(value))` — the integrity digest of a value. */
|
|
39
44
|
declare function canonicalDigest(value: unknown): string;
|
|
40
45
|
//#endregion
|
|
41
|
-
export { CanonicalizeError, canonicalDigest, canonicalJson, sha256Hex };
|
|
46
|
+
export { CanonicalizeError, MAX_CANONICAL_DEPTH, canonicalDigest, canonicalJson, sha256Hex };
|
package/dist/canonical.mjs
CHANGED
|
@@ -31,7 +31,12 @@ var CanonicalizeError = class extends Error {
|
|
|
31
31
|
this.name = "CanonicalizeError";
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Deepest nesting canonicalized — MongoDB's own document limit. Deeper input is refused
|
|
36
|
+
* rather than recursed: it may be hostile (a request body), and recursion would blow the stack.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_CANONICAL_DEPTH = 100;
|
|
39
|
+
function canonicalize(value, seen, depth = 0) {
|
|
35
40
|
if (value === null) return "null";
|
|
36
41
|
const t = typeof value;
|
|
37
42
|
if (t === "string") return JSON.stringify(value);
|
|
@@ -50,10 +55,11 @@ function canonicalize(value, seen) {
|
|
|
50
55
|
}
|
|
51
56
|
if (value instanceof Map || value instanceof Set) throw new CanonicalizeError(`${value.constructor.name} is not supported — use a plain object/array`);
|
|
52
57
|
const obj = value;
|
|
58
|
+
if (depth >= 100) throw new CanonicalizeError(`nesting deeper than 100 levels`);
|
|
53
59
|
if (seen.has(obj)) throw new CanonicalizeError("cyclic reference");
|
|
54
60
|
seen.add(obj);
|
|
55
61
|
try {
|
|
56
|
-
if (Array.isArray(value)) return `[${value.map((v) => canonicalize(v, seen)).join(",")}]`;
|
|
62
|
+
if (Array.isArray(value)) return `[${value.map((v) => canonicalize(v, seen, depth + 1)).join(",")}]`;
|
|
57
63
|
/**
|
|
58
64
|
* Reject CLASS INSTANCES — the silent-collapse hole.
|
|
59
65
|
*
|
|
@@ -72,7 +78,7 @@ function canonicalize(value, seen) {
|
|
|
72
78
|
const proto = Object.getPrototypeOf(value);
|
|
73
79
|
if (proto !== null && proto !== Object.prototype) throw new CanonicalizeError(`class instance '${value.constructor?.name ?? "unknown"}' is not serializable — its own enumerable keys are not its value, so it would digest as {} and silently defeat the checksum. Project it to a plain object first.`);
|
|
74
80
|
const rec = value;
|
|
75
|
-
return `{${Object.keys(rec).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(rec[k], seen)}`).join(",")}}`;
|
|
81
|
+
return `{${Object.keys(rec).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(rec[k], seen, depth + 1)}`).join(",")}}`;
|
|
76
82
|
} finally {
|
|
77
83
|
seen.delete(obj);
|
|
78
84
|
}
|
|
@@ -94,4 +100,4 @@ function canonicalDigest(value) {
|
|
|
94
100
|
return sha256Hex(canonicalJson(value));
|
|
95
101
|
}
|
|
96
102
|
//#endregion
|
|
97
|
-
export { CanonicalizeError, canonicalDigest, canonicalJson, sha256Hex };
|
|
103
|
+
export { CanonicalizeError, MAX_CANONICAL_DEPTH, canonicalDigest, canonicalJson, sha256Hex };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DomainEvent, EventMeta, EventTransport } from "./events.mjs";
|
|
2
|
+
import { ErrorLogger } from "./event-infra.mjs";
|
|
3
|
+
import { OutboxStore } from "./outbox.mjs";
|
|
4
|
+
//#region src/events/durable.d.ts
|
|
5
|
+
/** Required on every definition; no default, so neither side escapes by omission. */
|
|
6
|
+
type EventDurability = 'ephemeral' | 'durable';
|
|
7
|
+
/**
|
|
8
|
+
* A fact a durable event must eventually produce — attached at composition by its CONSUMER.
|
|
9
|
+
* Members are methods so a typed definition stays assignable to an `unknown`-payload registry.
|
|
10
|
+
*/
|
|
11
|
+
interface FactExpectation<P> {
|
|
12
|
+
/** `'journal-entry'`, `'stock-move'`, … — matched to a mounted probe. */
|
|
13
|
+
readonly kind: string;
|
|
14
|
+
/** The consumer's own key function: the keys its fact rows carry for this event. */
|
|
15
|
+
keys(payload: P, meta: EventMeta): readonly string[];
|
|
16
|
+
readonly graceMinutes: number;
|
|
17
|
+
/** `'unknown'` is a finding, never "does not apply". */
|
|
18
|
+
applies?(payload: P): boolean | 'unknown';
|
|
19
|
+
}
|
|
20
|
+
type DurableEventSpec<P> = {
|
|
21
|
+
readonly durability: 'ephemeral';
|
|
22
|
+
} | {
|
|
23
|
+
readonly durability: 'durable';
|
|
24
|
+
/** Aggregate id plus step (`fulfillmentId`, `${paymentId}:captured`) — never the order alone. */
|
|
25
|
+
occurrence(payload: P): string;
|
|
26
|
+
readonly produces?: readonly FactExpectation<P>[];
|
|
27
|
+
};
|
|
28
|
+
/** Where a publish happens. `deliveryOf` is the parent event id, set around a relayed delivery. */
|
|
29
|
+
interface DurableContext {
|
|
30
|
+
readonly session?: unknown;
|
|
31
|
+
readonly outbox?: OutboxStore | undefined;
|
|
32
|
+
readonly deliveryOf?: string | undefined;
|
|
33
|
+
}
|
|
34
|
+
declare class NonDurablePublishError extends Error {
|
|
35
|
+
readonly name = "NonDurablePublishError";
|
|
36
|
+
readonly code = "events.non_durable_publish";
|
|
37
|
+
readonly eventType: string;
|
|
38
|
+
constructor(eventType: string, reason: string);
|
|
39
|
+
}
|
|
40
|
+
/** An open session with neither an outbox nor a post-commit queue: publishing leaks, dropping loses. */
|
|
41
|
+
declare class UnmanagedSessionError extends Error {
|
|
42
|
+
readonly name = "UnmanagedSessionError";
|
|
43
|
+
readonly code = "events.unmanaged_session";
|
|
44
|
+
constructor(eventType: string);
|
|
45
|
+
}
|
|
46
|
+
/** `${name}:v${version}:${occurrence}` — the same occurrence always yields the same id. */
|
|
47
|
+
declare function eventIdFor(name: string, version: number, occurrence: string): string;
|
|
48
|
+
/** The meta a durable event is created with: a fixed id, and the same value as its idempotency key. */
|
|
49
|
+
declare function durableEventMeta(name: string, version: number, occurrence: string): Required<Pick<EventMeta, 'id' | 'idempotencyKey'>>;
|
|
50
|
+
/** Throws unless a durable type is in a unit of work (session + transactional store) or a durable delivery. */
|
|
51
|
+
declare function requireDurableContext(def: {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly durability: EventDurability;
|
|
54
|
+
}, ctx: DurableContext): void;
|
|
55
|
+
interface DispatchChannels {
|
|
56
|
+
readonly outbox: OutboxStore | undefined;
|
|
57
|
+
readonly transport: EventTransport | undefined;
|
|
58
|
+
/** With an outbox: `'relay'` = the row is the delivery; `'inline'` (default) = publish live, then acknowledge. */
|
|
59
|
+
readonly dispatch?: 'inline' | 'relay' | undefined;
|
|
60
|
+
/** Prefix for log lines, e.g. the kernel name. */
|
|
61
|
+
readonly label: string;
|
|
62
|
+
readonly logger?: ErrorLogger | undefined;
|
|
63
|
+
}
|
|
64
|
+
interface DispatchUnit extends Omit<DurableContext, 'outbox'> {
|
|
65
|
+
/** The post-commit queue of an open unit of work; its owner calls {@link flushDeferredEvents}. */
|
|
66
|
+
readonly pending?: DomainEvent[] | undefined;
|
|
67
|
+
}
|
|
68
|
+
/** `published` and `relay` are delivered or owned; `publish_failed` leaves a pending row (or nothing, with no outbox). */
|
|
69
|
+
type DispatchOutcome = 'deferred' | 'committed' | 'relay' | 'published' | 'publish_failed' | 'no_transport';
|
|
70
|
+
/**
|
|
71
|
+
* Emit one domain event. The outbox save runs first, in the session, and its failure propagates.
|
|
72
|
+
* In a unit of work delivery waits for {@link flushDeferredEvents}; otherwise an inline publish is
|
|
73
|
+
* acknowledged so the relay does not deliver it again, and a failed one stays pending for the relay.
|
|
74
|
+
*/
|
|
75
|
+
declare function dispatchDomainEvent(channels: DispatchChannels, unit: DispatchUnit, event: DomainEvent, durability: EventDurability): Promise<DispatchOutcome>;
|
|
76
|
+
/**
|
|
77
|
+
* Deliver a committed unit of work's queue. Under `'relay'` the rows are the delivery and nothing
|
|
78
|
+
* is published. Returns each published event's outcome (`null` = delivered and acknowledged).
|
|
79
|
+
* Delivered rows are acknowledged with one `acknowledgeMany` when the store has it.
|
|
80
|
+
*/
|
|
81
|
+
declare function flushDeferredEvents(channels: DispatchChannels, queue: DomainEvent[]): Promise<ReadonlyMap<string, Error | null>>;
|
|
82
|
+
//#endregion
|
|
83
|
+
export { DispatchChannels, DispatchOutcome, DispatchUnit, DurableContext, DurableEventSpec, EventDurability, FactExpectation, NonDurablePublishError, UnmanagedSessionError, dispatchDomainEvent, durableEventMeta, eventIdFor, flushDeferredEvents, requireDurableContext };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
//#region src/events/durable.ts
|
|
2
|
+
var NonDurablePublishError = class extends Error {
|
|
3
|
+
name = "NonDurablePublishError";
|
|
4
|
+
code = "events.non_durable_publish";
|
|
5
|
+
eventType;
|
|
6
|
+
constructor(eventType, reason) {
|
|
7
|
+
super(`Durable event "${eventType}" cannot be published here: ${reason}.`);
|
|
8
|
+
this.eventType = eventType;
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
/** An open session with neither an outbox nor a post-commit queue: publishing leaks, dropping loses. */
|
|
12
|
+
var UnmanagedSessionError = class extends Error {
|
|
13
|
+
name = "UnmanagedSessionError";
|
|
14
|
+
code = "events.unmanaged_session";
|
|
15
|
+
constructor(eventType) {
|
|
16
|
+
super(`Event "${eventType}" was emitted inside a caller-owned session with no outbox and no post-commit queue. Wire an outbox or run the write in the engine unit of work.`);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** `${name}:v${version}:${occurrence}` — the same occurrence always yields the same id. */
|
|
20
|
+
function eventIdFor(name, version, occurrence) {
|
|
21
|
+
if (typeof name !== "string" || name.length === 0) throw new TypeError("eventIdFor: event name must be a non-empty string");
|
|
22
|
+
if (!Number.isInteger(version) || version < 1) throw new TypeError(`eventIdFor(${name}): version must be a positive integer, got ${String(version)}`);
|
|
23
|
+
if (typeof occurrence !== "string" || occurrence.length === 0) throw new TypeError(`eventIdFor(${name}): occurrence must be a non-empty string, got ${typeof occurrence === "string" ? "\"\"" : typeof occurrence}`);
|
|
24
|
+
return `${name}:v${version}:${occurrence}`;
|
|
25
|
+
}
|
|
26
|
+
/** The meta a durable event is created with: a fixed id, and the same value as its idempotency key. */
|
|
27
|
+
function durableEventMeta(name, version, occurrence) {
|
|
28
|
+
const id = eventIdFor(name, version, occurrence);
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
idempotencyKey: id
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Throws unless a durable type is in a unit of work (session + transactional store) or a durable delivery. */
|
|
35
|
+
function requireDurableContext(def, ctx) {
|
|
36
|
+
if (def.durability === "ephemeral") return;
|
|
37
|
+
if (!ctx.outbox) throw new NonDurablePublishError(def.name, "no outbox is wired");
|
|
38
|
+
if (ctx.deliveryOf !== void 0) return;
|
|
39
|
+
if (ctx.session === void 0) throw new NonDurablePublishError(def.name, "no unit of work is open (no session) and this is not a durable delivery");
|
|
40
|
+
if (ctx.outbox.transactionalSave !== true) throw new NonDurablePublishError(def.name, "the outbox store does not declare transactionalSave");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Emit one domain event. The outbox save runs first, in the session, and its failure propagates.
|
|
44
|
+
* In a unit of work delivery waits for {@link flushDeferredEvents}; otherwise an inline publish is
|
|
45
|
+
* acknowledged so the relay does not deliver it again, and a failed one stays pending for the relay.
|
|
46
|
+
*/
|
|
47
|
+
async function dispatchDomainEvent(channels, unit, event, durability) {
|
|
48
|
+
requireDurableContext({
|
|
49
|
+
name: event.type,
|
|
50
|
+
durability
|
|
51
|
+
}, {
|
|
52
|
+
session: unit.session,
|
|
53
|
+
outbox: channels.outbox,
|
|
54
|
+
deliveryOf: unit.deliveryOf
|
|
55
|
+
});
|
|
56
|
+
const log = channels.logger ?? console;
|
|
57
|
+
if (channels.outbox) await channels.outbox.save(event, unit.session === void 0 ? {} : { session: unit.session });
|
|
58
|
+
else if (unit.session !== void 0 && !unit.pending) throw new UnmanagedSessionError(event.type);
|
|
59
|
+
if (unit.pending) {
|
|
60
|
+
unit.pending.push(event);
|
|
61
|
+
return "deferred";
|
|
62
|
+
}
|
|
63
|
+
if (unit.session !== void 0) return "committed";
|
|
64
|
+
if (channels.outbox && channels.dispatch === "relay") return "relay";
|
|
65
|
+
if (!channels.transport) return "no_transport";
|
|
66
|
+
try {
|
|
67
|
+
await channels.transport.publish(event);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
log.error(`[${channels.label}] publish failed for ${event.type} ${event.meta.id}:`, err);
|
|
70
|
+
return "publish_failed";
|
|
71
|
+
}
|
|
72
|
+
await acknowledgeDelivered(channels, event, log);
|
|
73
|
+
return "published";
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Deliver a committed unit of work's queue. Under `'relay'` the rows are the delivery and nothing
|
|
77
|
+
* is published. Returns each published event's outcome (`null` = delivered and acknowledged).
|
|
78
|
+
* Delivered rows are acknowledged with one `acknowledgeMany` when the store has it.
|
|
79
|
+
*/
|
|
80
|
+
async function flushDeferredEvents(channels, queue) {
|
|
81
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
82
|
+
const events = queue.splice(0);
|
|
83
|
+
if (channels.outbox && channels.dispatch === "relay" || !channels.transport) return outcomes;
|
|
84
|
+
const log = channels.logger ?? console;
|
|
85
|
+
for (const event of events) {
|
|
86
|
+
try {
|
|
87
|
+
await channels.transport.publish(event);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
outcomes.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
|
|
90
|
+
log.error(`[${channels.label}] publish failed for ${event.type} ${event.meta.id}:`, err);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
outcomes.set(event.meta.id, null);
|
|
94
|
+
}
|
|
95
|
+
const delivered = events.filter((e) => outcomes.get(e.meta.id) === null);
|
|
96
|
+
const outbox = channels.outbox;
|
|
97
|
+
if (outbox?.acknowledgeMany && delivered.length > 0) try {
|
|
98
|
+
await outbox.acknowledgeMany(delivered.map((e) => e.meta.id));
|
|
99
|
+
} catch (err) {
|
|
100
|
+
log.error(`[${channels.label}] outbox acknowledgeMany failed for ${delivered.length} events:`, err);
|
|
101
|
+
}
|
|
102
|
+
else for (const event of delivered) await acknowledgeDelivered(channels, event, log);
|
|
103
|
+
return outcomes;
|
|
104
|
+
}
|
|
105
|
+
/** A failed acknowledge only risks a redelivery, which consumers absorb by id; it never fails the caller. */
|
|
106
|
+
async function acknowledgeDelivered(channels, event, log) {
|
|
107
|
+
if (!channels.outbox) return;
|
|
108
|
+
try {
|
|
109
|
+
await channels.outbox.acknowledge(event.meta.id);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log.error(`[${channels.label}] outbox acknowledge failed for ${event.type} ${event.meta.id}:`, err);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
//#endregion
|
|
115
|
+
export { NonDurablePublishError, UnmanagedSessionError, dispatchDomainEvent, durableEventMeta, eventIdFor, flushDeferredEvents, requireDurableContext };
|
package/dist/environment.d.mts
CHANGED
|
@@ -86,5 +86,34 @@ declare function classifyEnv(env: string | undefined | null): EnvClass;
|
|
|
86
86
|
* published package is not free — it has to be supported, and it invited a needless version bump.
|
|
87
87
|
*/
|
|
88
88
|
declare function isProductionEnv(env: string | undefined | null): boolean;
|
|
89
|
+
/** A variable was SET to a value its reader does not accept. Names the variable and what it takes. */
|
|
90
|
+
declare class InvalidEnvError extends Error {
|
|
91
|
+
readonly code = "INVALID_ENV";
|
|
92
|
+
readonly variable: string;
|
|
93
|
+
readonly value: string;
|
|
94
|
+
readonly accepted: string;
|
|
95
|
+
constructor(variable: string, value: string, accepted: string);
|
|
96
|
+
}
|
|
97
|
+
/** A typed reader over an env record. Every method: unset or blank → the fallback; set but unrecognised → throws. */
|
|
98
|
+
interface EnvReader {
|
|
99
|
+
bool(name: string, fallback: boolean): boolean;
|
|
100
|
+
/** A whole number. A fallback of `undefined` makes the setting optional. */
|
|
101
|
+
int<F extends number | undefined>(name: string, fallback: F, bounds?: EnvBounds): number | F;
|
|
102
|
+
/** A finite decimal (a rate, a ratio). */
|
|
103
|
+
number<F extends number | undefined>(name: string, fallback: F, bounds?: EnvBounds): number | F;
|
|
104
|
+
oneOf<const T extends string>(name: string, allowed: readonly T[], fallback: T): T;
|
|
105
|
+
/** Comma-separated, trimmed, empty items dropped. Unset → `[]`. */
|
|
106
|
+
list(name: string): string[];
|
|
107
|
+
}
|
|
108
|
+
interface EnvBounds {
|
|
109
|
+
min?: number;
|
|
110
|
+
max?: number;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Read configuration strictly: a value that is SET must mean something, or boot fails naming it.
|
|
114
|
+
* A typo must never read as the default — `FEATURE=ture` silently becoming `false` switches a
|
|
115
|
+
* feature off with nothing said.
|
|
116
|
+
*/
|
|
117
|
+
declare function readEnv(env: Readonly<Record<string, string | undefined>>): EnvReader;
|
|
89
118
|
//#endregion
|
|
90
|
-
export { EnvClass, classifyEnv, isProductionEnv };
|
|
119
|
+
export { EnvBounds, EnvClass, EnvReader, InvalidEnvError, classifyEnv, isProductionEnv, readEnv };
|
package/dist/environment.mjs
CHANGED
|
@@ -49,5 +49,76 @@ function classifyEnv(env) {
|
|
|
49
49
|
function isProductionEnv(env) {
|
|
50
50
|
return classifyEnv(env) === "production";
|
|
51
51
|
}
|
|
52
|
+
/** A variable was SET to a value its reader does not accept. Names the variable and what it takes. */
|
|
53
|
+
var InvalidEnvError = class extends Error {
|
|
54
|
+
code = "INVALID_ENV";
|
|
55
|
+
variable;
|
|
56
|
+
value;
|
|
57
|
+
accepted;
|
|
58
|
+
constructor(variable, value, accepted) {
|
|
59
|
+
super(`${variable}=${JSON.stringify(value)} is not valid — expected ${accepted}.`);
|
|
60
|
+
this.name = "InvalidEnvError";
|
|
61
|
+
this.variable = variable;
|
|
62
|
+
this.value = value;
|
|
63
|
+
this.accepted = accepted;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
function inBounds(name, v, n, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER }, valid, kind) {
|
|
67
|
+
if (!valid(n) || n < min || n > max) throw new InvalidEnvError(name, v, `${kind} in [${min}, ${max}]`);
|
|
68
|
+
return n;
|
|
69
|
+
}
|
|
70
|
+
const TRUE = /* @__PURE__ */ new Set([
|
|
71
|
+
"true",
|
|
72
|
+
"1",
|
|
73
|
+
"yes",
|
|
74
|
+
"on"
|
|
75
|
+
]);
|
|
76
|
+
const FALSE = /* @__PURE__ */ new Set([
|
|
77
|
+
"false",
|
|
78
|
+
"0",
|
|
79
|
+
"no",
|
|
80
|
+
"off"
|
|
81
|
+
]);
|
|
82
|
+
/**
|
|
83
|
+
* Read configuration strictly: a value that is SET must mean something, or boot fails naming it.
|
|
84
|
+
* A typo must never read as the default — `FEATURE=ture` silently becoming `false` switches a
|
|
85
|
+
* feature off with nothing said.
|
|
86
|
+
*/
|
|
87
|
+
function readEnv(env) {
|
|
88
|
+
const raw = (name) => {
|
|
89
|
+
const v = env[name]?.trim();
|
|
90
|
+
return v ? v : void 0;
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
bool(name, fallback) {
|
|
94
|
+
const v = raw(name);
|
|
95
|
+
if (v === void 0) return fallback;
|
|
96
|
+
const lower = v.toLowerCase();
|
|
97
|
+
if (TRUE.has(lower)) return true;
|
|
98
|
+
if (FALSE.has(lower)) return false;
|
|
99
|
+
throw new InvalidEnvError(name, v, "true | false (also 1 | 0, yes | no, on | off)");
|
|
100
|
+
},
|
|
101
|
+
int(name, fallback, bounds = {}) {
|
|
102
|
+
const v = raw(name);
|
|
103
|
+
if (v === void 0) return fallback;
|
|
104
|
+
return inBounds(name, v, /^-?\d+$/.test(v) ? Number(v) : NaN, bounds, Number.isSafeInteger, "an integer");
|
|
105
|
+
},
|
|
106
|
+
number(name, fallback, bounds = {}) {
|
|
107
|
+
const v = raw(name);
|
|
108
|
+
if (v === void 0) return fallback;
|
|
109
|
+
return inBounds(name, v, /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : NaN, bounds, Number.isFinite, "a number");
|
|
110
|
+
},
|
|
111
|
+
oneOf(name, allowed, fallback) {
|
|
112
|
+
const v = raw(name);
|
|
113
|
+
if (v === void 0) return fallback;
|
|
114
|
+
if (allowed.includes(v)) return v;
|
|
115
|
+
throw new InvalidEnvError(name, v, allowed.join(" | "));
|
|
116
|
+
},
|
|
117
|
+
list(name) {
|
|
118
|
+
const v = raw(name);
|
|
119
|
+
return v ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
52
123
|
//#endregion
|
|
53
|
-
export { classifyEnv, isProductionEnv };
|
|
124
|
+
export { InvalidEnvError, classifyEnv, isProductionEnv, readEnv };
|