@classytic/primitives 0.28.0 → 0.29.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 +1203 -1119
- package/dist/approval.d.mts +26 -2
- package/dist/approval.mjs +26 -3
- package/dist/canonical.d.mts +6 -1
- package/dist/canonical.mjs +10 -4
- package/dist/environment.d.mts +30 -1
- package/dist/environment.mjs +72 -1
- package/dist/events.d.mts +39 -1
- package/dist/events.mjs +169 -1
- 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.mjs +2 -1
- package/dist/outbox.d.mts +20 -3
- 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/package.json +359 -285
package/dist/approval.d.mts
CHANGED
|
@@ -79,6 +79,18 @@ interface ApprovalChain {
|
|
|
79
79
|
* {@link isNotRequired} tests the structure instead, which no schema can drop.
|
|
80
80
|
*/
|
|
81
81
|
readonly notRequiredReason?: string;
|
|
82
|
+
/** Who asked, when, and against what — set by {@link attachRequest}. The requester may not approve it. */
|
|
83
|
+
readonly request?: ApprovalRequest;
|
|
84
|
+
}
|
|
85
|
+
/** The maker of an approval request, and the basis it was approved against. */
|
|
86
|
+
interface ApprovalRequest {
|
|
87
|
+
readonly by: string;
|
|
88
|
+
readonly at: Date;
|
|
89
|
+
/** What the approval covers. A later amount above `basis.amount` is not covered. */
|
|
90
|
+
readonly basis?: {
|
|
91
|
+
readonly amount?: number;
|
|
92
|
+
readonly category?: string;
|
|
93
|
+
};
|
|
82
94
|
}
|
|
83
95
|
interface CreateChainInput {
|
|
84
96
|
readonly order: ChainOrder;
|
|
@@ -101,7 +113,11 @@ type ApprovalErrorCode = 'EMPTY_STEPS' | 'DUPLICATE_STEP_ID' | 'EMPTY_APPROVERS'
|
|
|
101
113
|
* `CHAIN_INCOMPLETE`: the chain is not unfinished, it is absent — the
|
|
102
114
|
* document never carried one, or a schema that never declared the field
|
|
103
115
|
* stripped it on write. See {@link assertApproved}. */
|
|
104
|
-
'CHAIN_MISSING'
|
|
116
|
+
'CHAIN_MISSING' |
|
|
117
|
+
/** The requester tried to approve their own request (maker-checker). */
|
|
118
|
+
'SELF_APPROVAL' |
|
|
119
|
+
/** Removing the requester left a step with fewer approvers than its quorum. */
|
|
120
|
+
'NO_INDEPENDENT_APPROVER';
|
|
105
121
|
declare class ApprovalError extends Error {
|
|
106
122
|
readonly name = "ApprovalError";
|
|
107
123
|
readonly code: ApprovalErrorCode;
|
|
@@ -145,6 +161,14 @@ declare function createChain(input: CreateChainInput): ApprovalChain;
|
|
|
145
161
|
* @param reason Why approval was not required, for the audit trail.
|
|
146
162
|
*/
|
|
147
163
|
declare function notRequiredChain(reason: string): ApprovalChain;
|
|
164
|
+
/**
|
|
165
|
+
* Stamp the requester onto a chain and remove them from every step's approvers
|
|
166
|
+
* (maker-checker). A zero-step chain gets the request and nothing else.
|
|
167
|
+
*
|
|
168
|
+
* @throws ApprovalError `NO_INDEPENDENT_APPROVER` when a step is left with
|
|
169
|
+
* fewer approvers than `requiredApprovals` — nobody else can approve it.
|
|
170
|
+
*/
|
|
171
|
+
declare function attachRequest(chain: ApprovalChain, request: ApprovalRequest): ApprovalChain;
|
|
148
172
|
/**
|
|
149
173
|
* Returns the next step awaiting a decision — or `null` if none.
|
|
150
174
|
*
|
|
@@ -242,4 +266,4 @@ declare function isPending(chain: ApprovalChain): boolean;
|
|
|
242
266
|
*/
|
|
243
267
|
declare function decisionCount(chain: ApprovalChain): number;
|
|
244
268
|
//#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 };
|
|
269
|
+
export { ApprovalChain, ApprovalDecision, ApprovalError, ApprovalErrorCode, ApprovalRequest, ApprovalStatus, ApprovalStep, ApprovalThreshold, Approver, AssertApprovedOptions, ChainOrder, CreateChainInput, CreateStepInput, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
package/dist/approval.mjs
CHANGED
|
@@ -82,6 +82,28 @@ function notRequiredChain(reason) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
/**
|
|
85
|
+
* Stamp the requester onto a chain and remove them from every step's approvers
|
|
86
|
+
* (maker-checker). A zero-step chain gets the request and nothing else.
|
|
87
|
+
*
|
|
88
|
+
* @throws ApprovalError `NO_INDEPENDENT_APPROVER` when a step is left with
|
|
89
|
+
* fewer approvers than `requiredApprovals` — nobody else can approve it.
|
|
90
|
+
*/
|
|
91
|
+
function attachRequest(chain, request) {
|
|
92
|
+
const steps = chain.steps.map((step) => {
|
|
93
|
+
const approvers = step.approvers.filter((a) => a.id !== request.by);
|
|
94
|
+
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}`);
|
|
95
|
+
return approvers.length === step.approvers.length ? step : {
|
|
96
|
+
...step,
|
|
97
|
+
approvers
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
...chain,
|
|
102
|
+
steps,
|
|
103
|
+
request
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
85
107
|
* Returns the next step awaiting a decision — or `null` if none.
|
|
86
108
|
*
|
|
87
109
|
* For `sequential` chains: the first `pending` step in order; later steps
|
|
@@ -113,6 +135,7 @@ function applyDecision(chain, input) {
|
|
|
113
135
|
const step = chain.steps[stepIndex];
|
|
114
136
|
if (!step) throw new ApprovalError("UNKNOWN_STEP", `no step with id ${input.stepId}`);
|
|
115
137
|
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})`);
|
|
138
|
+
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
139
|
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
140
|
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
141
|
const decision = {
|
|
@@ -127,7 +150,7 @@ function applyDecision(chain, input) {
|
|
|
127
150
|
});
|
|
128
151
|
const updatedSteps = chain.steps.map((s, i) => i === stepIndex ? updatedStep : s);
|
|
129
152
|
return {
|
|
130
|
-
|
|
153
|
+
...chain,
|
|
131
154
|
steps: updatedSteps,
|
|
132
155
|
status: computeChainStatus(updatedSteps)
|
|
133
156
|
};
|
|
@@ -150,7 +173,7 @@ function skipStep(chain, stepId, reason) {
|
|
|
150
173
|
};
|
|
151
174
|
const updatedSteps = chain.steps.map((s, i) => i === stepIndex ? updatedStep : s);
|
|
152
175
|
return {
|
|
153
|
-
|
|
176
|
+
...chain,
|
|
154
177
|
steps: updatedSteps,
|
|
155
178
|
status: computeChainStatus(updatedSteps)
|
|
156
179
|
};
|
|
@@ -248,4 +271,4 @@ function computeChainStatus(steps) {
|
|
|
248
271
|
return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
|
|
249
272
|
}
|
|
250
273
|
//#endregion
|
|
251
|
-
export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
|
|
274
|
+
export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, 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 };
|
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 };
|
package/dist/events.d.mts
CHANGED
|
@@ -294,5 +294,43 @@ declare function createScopedEvent<T>(source: string, type: string, payload: T,
|
|
|
294
294
|
* - exact match otherwise
|
|
295
295
|
*/
|
|
296
296
|
declare function matchEventPattern(pattern: string, type: string): boolean;
|
|
297
|
+
/**
|
|
298
|
+
* Subscriptions indexed for dispatch — the same answer as scanning every pattern with
|
|
299
|
+
* `matchEventPattern` (same values, same order: first occurrence by pattern registration,
|
|
300
|
+
* deduped), in amortized O(1) per event instead of O(patterns).
|
|
301
|
+
*
|
|
302
|
+
* A wildcard can only match at a separator, so an event name has at most one candidate prefix
|
|
303
|
+
* per `.`/`:` — a lookup walks its own name, never the subscription table. Resolved lists are
|
|
304
|
+
* memoized per name (names are a small, closed set) and dropped on any add/remove.
|
|
305
|
+
*/
|
|
306
|
+
interface EventRouter<T> {
|
|
307
|
+
add(pattern: string, value: T): void;
|
|
308
|
+
/** `true` when the pair was registered. A pattern left with no values is dropped. */
|
|
309
|
+
remove(pattern: string, value: T): boolean;
|
|
310
|
+
/** Every value whose pattern matches `type` — registration order, each value once. */
|
|
311
|
+
route(type: string): readonly T[];
|
|
312
|
+
clear(): void;
|
|
313
|
+
/** Registered patterns, in registration order. */
|
|
314
|
+
patterns(): IterableIterator<[string, ReadonlySet<T>]>;
|
|
315
|
+
/** Number of registered patterns. */
|
|
316
|
+
readonly size: number;
|
|
317
|
+
}
|
|
318
|
+
declare function createEventRouter<T>(): EventRouter<T>;
|
|
319
|
+
/** One difference between two versions of an event's payload schema. */
|
|
320
|
+
interface EventSchemaChange {
|
|
321
|
+
/** Dotted path to the field (`''` = the payload root; `[]` = array items; `{}` = record values). */
|
|
322
|
+
readonly path: string;
|
|
323
|
+
readonly kind: 'added' | 'removed' | 'now-optional' | 'type-widened' | 'type-narrowed' | 'values-widened' | 'values-narrowed';
|
|
324
|
+
/** Would a consumer written against the OLD schema break on a payload valid under the NEW one? */
|
|
325
|
+
readonly breaking: boolean;
|
|
326
|
+
readonly detail: string;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* What changed between two versions of an event payload's JSON Schema, and whether each change
|
|
330
|
+
* breaks an existing CONSUMER (forward compatibility: the producer evolves, old consumers keep
|
|
331
|
+
* reading). Payloads are open for extension — adding a field never breaks, whatever
|
|
332
|
+
* `additionalProperties` says, because consumers ignore fields they do not know.
|
|
333
|
+
*/
|
|
334
|
+
declare function diffEventSchema(before: unknown, after: unknown, path?: string): EventSchemaChange[];
|
|
297
335
|
//#endregion
|
|
298
|
-
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventScopeContext, EventTransport, PublishManyResult, createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
|
336
|
+
export { DeadLetteredEvent, DomainEvent, EventHandler, EventLogger, EventMeta, EventRouter, EventSchemaChange, EventScopeContext, EventTransport, PublishManyResult, createChildEvent, createEvent, createEventRouter, createScopedEvent, diffEventSchema, matchEventPattern, scopedEventMeta };
|
package/dist/events.mjs
CHANGED
|
@@ -127,5 +127,173 @@ function randomEventId() {
|
|
|
127
127
|
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
|
+
/** Past this many distinct names the memo is reset — dynamic names must not grow it without bound. */
|
|
131
|
+
const ROUTE_MEMO_LIMIT = 4096;
|
|
132
|
+
function createEventRouter() {
|
|
133
|
+
const byPattern = /* @__PURE__ */ new Map();
|
|
134
|
+
const order = /* @__PURE__ */ new Map();
|
|
135
|
+
let seq = 0;
|
|
136
|
+
const memo = /* @__PURE__ */ new Map();
|
|
137
|
+
const resolve = (type) => {
|
|
138
|
+
const hits = [];
|
|
139
|
+
if (byPattern.has("*")) hits.push("*");
|
|
140
|
+
if (byPattern.has(type)) hits.push(type);
|
|
141
|
+
for (let i = 0; i < type.length; i++) {
|
|
142
|
+
const c = type.charCodeAt(i);
|
|
143
|
+
if (c !== 46 && c !== 58) continue;
|
|
144
|
+
const wildcard = `${type.slice(0, i + 1)}*`;
|
|
145
|
+
if (wildcard !== type && byPattern.has(wildcard)) hits.push(wildcard);
|
|
146
|
+
}
|
|
147
|
+
if (hits.length > 1) hits.sort((a, b) => order.get(a) - order.get(b));
|
|
148
|
+
const seen = /* @__PURE__ */ new Set();
|
|
149
|
+
for (const pattern of hits) for (const v of byPattern.get(pattern)) seen.add(v);
|
|
150
|
+
return Object.freeze([...seen]);
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
add(pattern, value) {
|
|
154
|
+
let set = byPattern.get(pattern);
|
|
155
|
+
if (!set) {
|
|
156
|
+
set = /* @__PURE__ */ new Set();
|
|
157
|
+
byPattern.set(pattern, set);
|
|
158
|
+
order.set(pattern, seq++);
|
|
159
|
+
}
|
|
160
|
+
if (!set.has(value)) {
|
|
161
|
+
set.add(value);
|
|
162
|
+
memo.clear();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
remove(pattern, value) {
|
|
166
|
+
const set = byPattern.get(pattern);
|
|
167
|
+
if (!set?.delete(value)) return false;
|
|
168
|
+
if (set.size === 0) {
|
|
169
|
+
byPattern.delete(pattern);
|
|
170
|
+
order.delete(pattern);
|
|
171
|
+
}
|
|
172
|
+
memo.clear();
|
|
173
|
+
return true;
|
|
174
|
+
},
|
|
175
|
+
route(type) {
|
|
176
|
+
const cached = memo.get(type);
|
|
177
|
+
if (cached) return cached;
|
|
178
|
+
const resolved = resolve(type);
|
|
179
|
+
if (memo.size >= ROUTE_MEMO_LIMIT) memo.clear();
|
|
180
|
+
memo.set(type, resolved);
|
|
181
|
+
return resolved;
|
|
182
|
+
},
|
|
183
|
+
clear() {
|
|
184
|
+
byPattern.clear();
|
|
185
|
+
order.clear();
|
|
186
|
+
memo.clear();
|
|
187
|
+
},
|
|
188
|
+
patterns: () => byPattern.entries(),
|
|
189
|
+
get size() {
|
|
190
|
+
return byPattern.size;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const jsonType = (v) => v === null ? "null" : Array.isArray(v) ? "array" : Number.isInteger(v) ? "integer" : typeof v;
|
|
195
|
+
/** The JSON types a node admits; `null` means unconstrained. */
|
|
196
|
+
function typesOf(node) {
|
|
197
|
+
const branches = node.anyOf ?? node.oneOf;
|
|
198
|
+
if (branches) {
|
|
199
|
+
const all = /* @__PURE__ */ new Set();
|
|
200
|
+
for (const b of branches) {
|
|
201
|
+
const t = typesOf(b);
|
|
202
|
+
if (t === null) return null;
|
|
203
|
+
for (const x of t) all.add(x);
|
|
204
|
+
}
|
|
205
|
+
return all;
|
|
206
|
+
}
|
|
207
|
+
if (node.const !== void 0) return /* @__PURE__ */ new Set([jsonType(node.const)]);
|
|
208
|
+
if (node.enum) return new Set(node.enum.map(jsonType));
|
|
209
|
+
if (node.type === void 0) return null;
|
|
210
|
+
return new Set([node.type].flat());
|
|
211
|
+
}
|
|
212
|
+
/** The finite values a node admits (`enum` / `const`), or `null` when open. */
|
|
213
|
+
function valuesOf(node) {
|
|
214
|
+
if (node.const !== void 0) return /* @__PURE__ */ new Set([JSON.stringify(node.const)]);
|
|
215
|
+
if (node.enum) return new Set(node.enum.map((v) => JSON.stringify(v)));
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/** `integer` is a kind of `number`. */
|
|
219
|
+
const admits = (types, t) => types.has(t) || t === "integer" && types.has("number");
|
|
220
|
+
/**
|
|
221
|
+
* What changed between two versions of an event payload's JSON Schema, and whether each change
|
|
222
|
+
* breaks an existing CONSUMER (forward compatibility: the producer evolves, old consumers keep
|
|
223
|
+
* reading). Payloads are open for extension — adding a field never breaks, whatever
|
|
224
|
+
* `additionalProperties` says, because consumers ignore fields they do not know.
|
|
225
|
+
*/
|
|
226
|
+
function diffEventSchema(before, after, path = "") {
|
|
227
|
+
const a = before ?? {};
|
|
228
|
+
const b = after ?? {};
|
|
229
|
+
const changes = [];
|
|
230
|
+
const at = (field) => path ? `${path}.${field}` : field;
|
|
231
|
+
const ta = typesOf(a);
|
|
232
|
+
const tb = typesOf(b);
|
|
233
|
+
if (tb === null ? ta !== null : ta !== null && [...tb].some((t) => !admits(ta, t))) changes.push({
|
|
234
|
+
path,
|
|
235
|
+
kind: "type-widened",
|
|
236
|
+
breaking: true,
|
|
237
|
+
detail: `${ta ? [...ta].join("|") : "any"} → ${tb ? [...tb].join("|") : "any"}`
|
|
238
|
+
});
|
|
239
|
+
else if (ta !== null && tb !== null && [...ta].some((t) => !admits(tb, t))) changes.push({
|
|
240
|
+
path,
|
|
241
|
+
kind: "type-narrowed",
|
|
242
|
+
breaking: false,
|
|
243
|
+
detail: `${[...ta].join("|")} → ${[...tb].join("|")}`
|
|
244
|
+
});
|
|
245
|
+
const va = valuesOf(a);
|
|
246
|
+
const vb = valuesOf(b);
|
|
247
|
+
if (va !== null) {
|
|
248
|
+
const added = vb === null ? ["(any)"] : [...vb].filter((v) => !va.has(v));
|
|
249
|
+
const removed = vb === null ? [] : [...va].filter((v) => !vb.has(v));
|
|
250
|
+
if (added.length > 0) changes.push({
|
|
251
|
+
path,
|
|
252
|
+
kind: "values-widened",
|
|
253
|
+
breaking: true,
|
|
254
|
+
detail: `new values ${added.join(", ")}`
|
|
255
|
+
});
|
|
256
|
+
if (removed.length > 0) changes.push({
|
|
257
|
+
path,
|
|
258
|
+
kind: "values-narrowed",
|
|
259
|
+
breaking: false,
|
|
260
|
+
detail: `dropped ${removed.join(", ")}`
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const pa = a.properties ?? {};
|
|
264
|
+
const pb = b.properties ?? {};
|
|
265
|
+
const ra = new Set(a.required ?? []);
|
|
266
|
+
const rb = new Set(b.required ?? []);
|
|
267
|
+
for (const field of Object.keys(pa)) {
|
|
268
|
+
if (!(field in pb)) {
|
|
269
|
+
const required = ra.has(field);
|
|
270
|
+
changes.push({
|
|
271
|
+
path: at(field),
|
|
272
|
+
kind: "removed",
|
|
273
|
+
breaking: required,
|
|
274
|
+
detail: required ? "required field removed" : "optional field removed"
|
|
275
|
+
});
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (ra.has(field) && !rb.has(field)) changes.push({
|
|
279
|
+
path: at(field),
|
|
280
|
+
kind: "now-optional",
|
|
281
|
+
breaking: true,
|
|
282
|
+
detail: "was required"
|
|
283
|
+
});
|
|
284
|
+
changes.push(...diffEventSchema(pa[field], pb[field], at(field)));
|
|
285
|
+
}
|
|
286
|
+
for (const field of Object.keys(pb)) if (!(field in pa)) changes.push({
|
|
287
|
+
path: at(field),
|
|
288
|
+
kind: "added",
|
|
289
|
+
breaking: false,
|
|
290
|
+
detail: rb.has(field) ? "required" : "optional"
|
|
291
|
+
});
|
|
292
|
+
if (a.items || b.items) changes.push(...diffEventSchema(a.items, b.items, `${path}[]`));
|
|
293
|
+
const aa = typeof a.additionalProperties === "object" ? a.additionalProperties : void 0;
|
|
294
|
+
const ba = typeof b.additionalProperties === "object" ? b.additionalProperties : void 0;
|
|
295
|
+
if (aa || ba) changes.push(...diffEventSchema(aa, ba, `${path}{}`));
|
|
296
|
+
return changes;
|
|
297
|
+
}
|
|
130
298
|
//#endregion
|
|
131
|
-
export { createChildEvent, createEvent, createScopedEvent, matchEventPattern, scopedEventMeta };
|
|
299
|
+
export { createChildEvent, createEvent, createEventRouter, createScopedEvent, diffEventSchema, matchEventPattern, scopedEventMeta };
|
package/dist/idempotency.d.mts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
//#region src/workflow/idempotency.d.ts
|
|
2
2
|
/**
|
|
3
|
+
* @deprecated Moved to `@classytic/repo-core/idempotency` — the claim contract plus its store
|
|
4
|
+
* port, `runIdempotent`, the reference store and the conformance suite, beside `./lock`. This
|
|
5
|
+
* copy has no consumers and is removed in the next minor.
|
|
6
|
+
*
|
|
3
7
|
* Idempotency claim + lease — the CONTRACT, not the store.
|
|
4
8
|
*
|
|
5
9
|
* ## Why this exists
|
package/dist/idempotency.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { canonicalDigest, canonicalJson } from "./canonical.mjs";
|
|
2
2
|
//#region src/workflow/idempotency.ts
|
|
3
3
|
/**
|
|
4
|
+
* @deprecated Moved to `@classytic/repo-core/idempotency` — the claim contract plus its store
|
|
5
|
+
* port, `runIdempotent`, the reference store and the conformance suite, beside `./lock`. This
|
|
6
|
+
* copy has no consumers and is removed in the next minor.
|
|
7
|
+
*
|
|
4
8
|
* Idempotency claim + lease — the CONTRACT, not the store.
|
|
5
9
|
*
|
|
6
10
|
* ## Why this exists
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { SkuFields, StockKey, StockKeySource } from "./item-identity.mjs";
|
|
2
|
+
//#region src/inventory/item-stock.d.ts
|
|
3
|
+
/** One stock row as the summary reads it. */
|
|
4
|
+
interface StockRow {
|
|
5
|
+
readonly quantity?: number;
|
|
6
|
+
/** Explicitly `false` deactivates the row. Absent means active. */
|
|
7
|
+
readonly isActive?: boolean;
|
|
8
|
+
/** Display cost of one unit, as the source reports it. */
|
|
9
|
+
readonly costPrice?: number;
|
|
10
|
+
/** On-hand value at the source's valuation, integer MINOR units; absent = unvalued. */
|
|
11
|
+
readonly valueMinor?: number;
|
|
12
|
+
/** Units at or below which the item reads as low (the item-level row's). */
|
|
13
|
+
readonly reorderPoint?: number;
|
|
14
|
+
}
|
|
15
|
+
/** What a reader shows for an item's stock in one place. */
|
|
16
|
+
interface ItemStock {
|
|
17
|
+
quantity: number;
|
|
18
|
+
inStock: boolean;
|
|
19
|
+
lowStock: boolean;
|
|
20
|
+
valueMinor?: number;
|
|
21
|
+
/** Per variant LABEL — a projection of the same rows, not a second source. */
|
|
22
|
+
variants?: Array<{
|
|
23
|
+
sku: string;
|
|
24
|
+
quantity: number;
|
|
25
|
+
costPrice?: number;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
/** The item shape the summary reads: its id and its variants. */
|
|
29
|
+
interface StockableItem extends StockKeySource {
|
|
30
|
+
readonly variants?: ReadonlyArray<SkuFields & {
|
|
31
|
+
readonly sku: string;
|
|
32
|
+
readonly isActive?: boolean;
|
|
33
|
+
}> | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The item's stock. `rowOf(null)` is the item-level row (filed under the item's own id);
|
|
37
|
+
* `rowOf(key)` the row filed under one variant's stock key.
|
|
38
|
+
*/
|
|
39
|
+
declare function itemStockOf(item: StockableItem, rowOf: (stockKey: StockKey | null) => StockRow | undefined, options: {
|
|
40
|
+
readonly defaultReorderPoint: number;
|
|
41
|
+
}): ItemStock;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { ItemStock, StockRow, StockableItem, itemStockOf };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { stockKeyFor } from "./item-identity.mjs";
|
|
2
|
+
//#region src/inventory/item-stock.ts
|
|
3
|
+
/**
|
|
4
|
+
* An item's stock in one place (a branch), from the rows its stock source files under stock keys —
|
|
5
|
+
* the ONE summary every reader shows: the server's product reads and an offline till alike.
|
|
6
|
+
*
|
|
7
|
+
* itemStockOf(product, (key) => rows.get(key ?? productId), { defaultReorderPoint: 10 })
|
|
8
|
+
*
|
|
9
|
+
* Rows are keyed by STOCK KEY (`stockKeyFor`), and a row reached by two lookups is counted once —
|
|
10
|
+
* a sole variant's key IS the product id, so summing lookups instead of rows doubles it.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The item's stock. `rowOf(null)` is the item-level row (filed under the item's own id);
|
|
14
|
+
* `rowOf(key)` the row filed under one variant's stock key.
|
|
15
|
+
*/
|
|
16
|
+
function itemStockOf(item, rowOf, options) {
|
|
17
|
+
const itemId = String(item._id);
|
|
18
|
+
const variants = item.variants ?? [];
|
|
19
|
+
const keyOf = (variant) => stockKeyFor({
|
|
20
|
+
_id: itemId,
|
|
21
|
+
variants
|
|
22
|
+
}, {
|
|
23
|
+
kind: "variant",
|
|
24
|
+
variant
|
|
25
|
+
});
|
|
26
|
+
const rows = /* @__PURE__ */ new Map();
|
|
27
|
+
const itemRow = rowOf(null);
|
|
28
|
+
if (itemRow) rows.set(itemId, itemRow);
|
|
29
|
+
for (const variant of variants) {
|
|
30
|
+
if (variant.isActive === false) continue;
|
|
31
|
+
const key = keyOf(variant);
|
|
32
|
+
const row = rowOf(key);
|
|
33
|
+
if (!row && rows.has(key)) continue;
|
|
34
|
+
rows.set(key, row);
|
|
35
|
+
}
|
|
36
|
+
let quantity = 0;
|
|
37
|
+
let valueMinor = 0;
|
|
38
|
+
for (const row of rows.values()) {
|
|
39
|
+
if (row?.isActive === false) continue;
|
|
40
|
+
const rowQuantity = row?.quantity ?? 0;
|
|
41
|
+
quantity += rowQuantity;
|
|
42
|
+
if (rowQuantity > 0) valueMinor = row?.valueMinor === void 0 || valueMinor === void 0 ? void 0 : valueMinor + row.valueMinor;
|
|
43
|
+
}
|
|
44
|
+
const variantStocks = [];
|
|
45
|
+
for (const variant of variants) {
|
|
46
|
+
if (variant.isActive === false) continue;
|
|
47
|
+
const key = keyOf(variant);
|
|
48
|
+
if (!rows.has(key)) continue;
|
|
49
|
+
const row = rows.get(key);
|
|
50
|
+
if (row?.isActive === false) continue;
|
|
51
|
+
variantStocks.push({
|
|
52
|
+
sku: variant.sku,
|
|
53
|
+
quantity: row?.quantity ?? 0,
|
|
54
|
+
...row?.costPrice !== void 0 ? { costPrice: row.costPrice } : {}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const reorderPoint = itemRow?.reorderPoint ?? options.defaultReorderPoint;
|
|
58
|
+
return {
|
|
59
|
+
quantity,
|
|
60
|
+
inStock: quantity > 0,
|
|
61
|
+
lowStock: quantity > 0 && quantity <= reorderPoint,
|
|
62
|
+
...quantity > 0 && valueMinor !== void 0 ? { valueMinor } : {},
|
|
63
|
+
...variantStocks.length ? { variants: variantStocks } : {}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { itemStockOf };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/identity/legal-form.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The legal form of a person or entity — what the law treats it as, for the reporting company and its
|
|
4
|
+
* counterparties alike. A sole proprietorship is an `individual`: the business has no legal personality
|
|
5
|
+
* of its own. Country packs decide what follows from a form (equity accounts, the year-end close, tax).
|
|
6
|
+
*/
|
|
7
|
+
declare const LEGAL_FORMS: readonly ["individual", "partnership", "company", "cooperative", "nonprofit", "government"];
|
|
8
|
+
type LegalForm = (typeof LEGAL_FORMS)[number];
|
|
9
|
+
declare const LEGAL_FORM_LABELS: Readonly<Record<LegalForm, string>>;
|
|
10
|
+
declare function isLegalForm(value: unknown): value is LegalForm;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { LEGAL_FORMS, LEGAL_FORM_LABELS, LegalForm, isLegalForm };
|