@classytic/primitives 0.27.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 +122 -0
- package/dist/approval.d.mts +26 -2
- package/dist/approval.mjs +26 -3
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/canonical.d.mts +6 -1
- package/dist/canonical.mjs +10 -4
- package/dist/currency.d.mts +1 -1
- 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/monetization.d.mts +92 -6
- package/dist/monetization.mjs +68 -5
- package/dist/money-BBOARE5f.mjs +204 -0
- package/dist/{money-CfAvfNiM.d.mts → money-C9iLWiWR.d.mts} +21 -1
- package/dist/money.d.mts +2 -2
- package/dist/money.mjs +3 -177
- package/dist/outbox.d.mts +20 -3
- package/dist/payment-events.d.mts +7 -1
- package/dist/payment-gateway.d.mts +1 -1
- 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 +12 -2
- package/dist/proration.mjs +24 -1
- package/dist/signed-token.d.mts +54 -0
- package/dist/signed-token.mjs +139 -0
- package/dist/unit-cost-rate.d.mts +1 -1
- package/dist/unit-cost-rate.mjs +1 -1
- package/package.json +75 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,128 @@
|
|
|
3
3
|
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
4
4
|
adhering to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## 0.29.0 — 2026-10-08
|
|
7
|
+
|
|
8
|
+
### Added — `./signed-token`: ES256 compact JWS over WebCrypto
|
|
9
|
+
`signToken` / `verifyToken` for a server and an offline device, no dependency. `typ` is required on both
|
|
10
|
+
sides and a token without `exp` is refused; every failure is a named refusal (`bad_signature`,
|
|
11
|
+
`wrong_type`, `unknown_key`, `expired`, …), never a throw. Signing keys import non-extractable.
|
|
12
|
+
Pinned by `tests/unit/signed-token.test.ts` (the expiry rule is falsified).
|
|
13
|
+
|
|
14
|
+
### Added — `itemStockOf` (`./item-stock`): one item's stock, the one summary every reader shows
|
|
15
|
+
`itemStockOf(item, rowOf, { defaultReorderPoint })` builds `{ quantity, inStock, lowStock, valueMinor?,
|
|
16
|
+
variants? }` from rows filed under stock keys (`stockKeyFor`). A row reached by both the item-level
|
|
17
|
+
and a variant lookup counts once; production's sole variant keys by the product id. It is pure and
|
|
18
|
+
browser-safe, so the server's catalog bridge and an offline till compute the same answer. Pinned by
|
|
19
|
+
`tests/unit/item-stock.test.ts`; collapsing the row de-duplication fails three of its cases.
|
|
20
|
+
|
|
21
|
+
### Added — maker-checker on `ApprovalChain` (`./approval`)
|
|
22
|
+
`ApprovalChain.request` (`{ by, at, basis? }`) records who asked and what the approval covers.
|
|
23
|
+
`attachRequest(chain, request)` removes the requester from every step's approvers and throws
|
|
24
|
+
`NO_INDEPENDENT_APPROVER` when a step is left below its quorum. `applyDecision` throws
|
|
25
|
+
`SELF_APPROVAL` when the requester approves (rejecting their own request — a withdrawal — is
|
|
26
|
+
allowed). `applyDecision` and `skipStep` now preserve `request` and `notRequiredReason` instead of
|
|
27
|
+
rebuilding the chain from `{ order, steps, status }`.
|
|
28
|
+
|
|
29
|
+
### Added — `diffEventSchema` (`./events`)
|
|
30
|
+
Compares two versions of an event payload's JSON Schema and marks each change breaking or not
|
|
31
|
+
for an existing CONSUMER (forward compatibility): a required field removed, a field made optional,
|
|
32
|
+
a widened type or enum break; additions and narrowing do not. Payloads are open for extension, so
|
|
33
|
+
`additionalProperties: false` is disregarded. Nested objects, array items and record values recurse.
|
|
34
|
+
|
|
35
|
+
### Added — `createEventRouter` (`./events`)
|
|
36
|
+
An index over event subscriptions with `matchEventPattern`'s exact semantics — same values, same
|
|
37
|
+
order, deduped — in amortized O(1) per route: candidates are only the name's separator prefixes,
|
|
38
|
+
and results are memoized per name (bounded) until `add`/`remove`. Pinned by a property test
|
|
39
|
+
against the scan. Transports and graph analyses route through it instead of scanning.
|
|
40
|
+
|
|
41
|
+
### Added — `readEnv`: strict configuration reads (`./environment`)
|
|
42
|
+
`readEnv(process.env)` → `bool | int | number | oneOf | list`, plus `InvalidEnvError`. Unset or
|
|
43
|
+
blank → the declared default; a value that is SET but unrecognised throws, naming the variable
|
|
44
|
+
and what it accepts. Booleans take `true/false`, `1/0`, `yes/no`, `on/off`; numbers take bounds.
|
|
45
|
+
A hand-rolled `=== 'true'` or `parseInt` widens a typo to the default and says nothing.
|
|
46
|
+
|
|
47
|
+
### Added — placeholder phone, one definition (`./phone`)
|
|
48
|
+
`PLACEHOLDER_PHONE_PREFIX`, `placeholderPhone(id)`, `isPlaceholderPhone(v)`, `shownPhone(v)`.
|
|
49
|
+
A record whose `phone` is required and unique but unknown stores `pending_<id>`; the writer
|
|
50
|
+
(`@spinekit/order`) and every reader (be-prod party linking, the SDK, commerce-ui, pos-ui,
|
|
51
|
+
the storefront) now share this instead of five hand-written `startsWith('pending_')`.
|
|
52
|
+
|
|
53
|
+
### Deprecated: `@classytic/primitives/idempotency` — moved to `@classytic/repo-core/idempotency`
|
|
54
|
+
|
|
55
|
+
The claim contract had no consumers here. It now lives beside `repo-core/lock` with its store
|
|
56
|
+
port, `runIdempotent`, the reference store and the conformance suite, so every persistence kit
|
|
57
|
+
implements it without depending on this package. Removed in the next minor.
|
|
58
|
+
|
|
59
|
+
### Changed: `canonicalJson` refuses nesting deeper than 100 levels
|
|
60
|
+
|
|
61
|
+
`MAX_CANONICAL_DEPTH` (MongoDB's document limit). Deeper input throws `CanonicalizeError`
|
|
62
|
+
instead of recursing — a hostile input used to overflow the stack.
|
|
63
|
+
|
|
64
|
+
### Added: `@classytic/primitives/line-settlement` (incl. `shareOfSettled`)
|
|
65
|
+
|
|
66
|
+
One definition of "how much of this document line is still open". A line's
|
|
67
|
+
quantity splits into kernel-named outcome buckets (fulfilled / returned /
|
|
68
|
+
received / written off …); `outstandingOf`, `settlementState`,
|
|
69
|
+
`documentSettlementState` and `settledEntirelyIn` derive the rest, and
|
|
70
|
+
`planSettlement` REFUSES a non-integer or over-outstanding quantity instead of
|
|
71
|
+
trimming it (`clampSettlement` exists for surfaces whose contract is "take what
|
|
72
|
+
fits"). Order, flow, purchase and transfer each derived this by hand; transfer
|
|
73
|
+
adopts it first.
|
|
74
|
+
|
|
75
|
+
### Added: `OutboxClaimedEvent.attempts`, the durable attempt count
|
|
76
|
+
|
|
77
|
+
`OutboxFailureContext.attempts` was documented as "tracked in-process by the
|
|
78
|
+
relay; resets on restart", and that is exactly where a failure policy is wrong.
|
|
79
|
+
`attempts >= 5 → deadLetter` never fired for a poison event that crashes the
|
|
80
|
+
relay (it came back at 1 on every boot), and across N relays sharing a store it
|
|
81
|
+
meant 5×N deliveries. Stores already increment a per-row counter atomically
|
|
82
|
+
inside the claim; they had no way to report it.
|
|
83
|
+
|
|
84
|
+
`OutboxClaimedEvent` (the fenced-claim result) gains an optional
|
|
85
|
+
`attempts?: number`, which counts every claim including the one that returned it.
|
|
86
|
+
`MemoryOutboxStore` reports it. Optional and additive: stores that don't count
|
|
87
|
+
leave it absent, and relays fall back to their own count and take the larger of
|
|
88
|
+
the two.
|
|
89
|
+
|
|
90
|
+
## 0.28.0
|
|
91
|
+
|
|
92
|
+
### Added: a `pass` monetization kind (paid once, grants a term, never renews)
|
|
93
|
+
|
|
94
|
+
The union could say "charged once" (`one_time`, a single price, no span) and
|
|
95
|
+
"recurring" (`subscription`, priced spans that renew). It could not say
|
|
96
|
+
"prepaid term": a course access pass, a gym or transit pass, a parking permit,
|
|
97
|
+
a term licence. Hosts selling one reached for `subscription`, the only shape
|
|
98
|
+
that carried priced spans, and then had to explain at every read that it does
|
|
99
|
+
not actually renew. `toRevenueMonetizationType` would also have settled those
|
|
100
|
+
sales as recurring income, forecasting revenue nothing was scheduled to bill.
|
|
101
|
+
|
|
102
|
+
`PassMonetization { type: 'pass', passes: AccessPass[] }`, where an
|
|
103
|
+
`AccessPass` is a key, a label, a price, an optional "was" price, and a
|
|
104
|
+
`duration` + `durationUnit`. Shaped like `SubscriptionPlan` so a term means
|
|
105
|
+
the same thing everywhere, kept separate because a plan carries `trialDays`
|
|
106
|
+
and a renewal discount that a pass has none of.
|
|
107
|
+
|
|
108
|
+
Also: `isPassMonetization`, `defaultPassOf` (the cheapest term, ties broken on
|
|
109
|
+
the shorter SPAN so the headline is deterministic), and `approxTermDays` for
|
|
110
|
+
ordering and labels only. `unitPriceOf` returns the cheapest term's price for
|
|
111
|
+
a pass, `compareAtPriceOf` now returns the "was" of that SAME term so a card
|
|
112
|
+
can never strike one term's price through beside another's, and
|
|
113
|
+
`toRevenueMonetizationType('pass')` is `purchase`.
|
|
114
|
+
|
|
115
|
+
A perpetual sale stays `one_time`: "forever" is the absence of a term, not a
|
|
116
|
+
very long one.
|
|
117
|
+
|
|
118
|
+
### Added — `CashDestination` on `./money`
|
|
119
|
+
|
|
120
|
+
`CASH_DESTINATIONS` (`'cash' | 'petty_cash'`), `isCashDestination` and
|
|
121
|
+
`cashDestinationLabel`: WHERE received cash landed, as an intent — never a
|
|
122
|
+
chart-of-accounts code. One vocabulary for a request body, a domain event, a
|
|
123
|
+
client SDK and a settlement dialog, so the accounting tier alone maps it to the
|
|
124
|
+
active chart and a renumber cannot reach any of them. Added because a client
|
|
125
|
+
carried a literal bank code the chart had already renumbered, and every
|
|
126
|
+
settlement it sent was refused.
|
|
127
|
+
|
|
6
128
|
## 0.27.0
|
|
7
129
|
|
|
8
130
|
### Added — `./csv` subpath: one CSV cell escaper for the whole fleet
|
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/currency.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as FxRounding, B as reverseWithSnapshot, D as CURRENCY_PATTERN, E as CURRENCIES, F as currencyCode, I as isCurrencyCode, L as isFxSnapshot, M as InvalidCurrencyCodeError, N as MINOR_UNIT_FACTOR, O as CurrencyCode, P as convertWithSnapshot, R as isKnownCurrency, V as toCurrencyCode, j as FxSnapshot, k as CurrencyMismatchError, z as minorUnitFactor } from "./money-C9iLWiWR.mjs";
|
|
2
2
|
export { CURRENCIES, CURRENCY_PATTERN, CurrencyCode, CurrencyMismatchError, FxRounding, FxSnapshot, InvalidCurrencyCodeError, MINOR_UNIT_FACTOR, convertWithSnapshot, currencyCode, isCurrencyCode, isFxSnapshot, isKnownCurrency, minorUnitFactor, reverseWithSnapshot, toCurrencyCode };
|
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
|