@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 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
@@ -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
- order: chain.order,
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
- order: chain.order,
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 };
@@ -1,4 +1,4 @@
1
- import { n as Money } from "./money-CfAvfNiM.mjs";
1
+ import { n as Money } from "./money-C9iLWiWR.mjs";
2
2
  //#region src/money/bank-transaction.d.ts
3
3
  /**
4
4
  * The other side of a bank transaction. All fields optional — different
@@ -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 };
@@ -31,7 +31,12 @@ var CanonicalizeError = class extends Error {
31
31
  this.name = "CanonicalizeError";
32
32
  }
33
33
  };
34
- function canonicalize(value, seen) {
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 };
@@ -1,2 +1,2 @@
1
- import { A as convertWithSnapshot, C as CURRENCY_PATTERN, D as FxSnapshot, E as FxRounding, F as minorUnitFactor, I as reverseWithSnapshot, L as toCurrencyCode, M as isCurrencyCode, N as isFxSnapshot, O as InvalidCurrencyCodeError, P as isKnownCurrency, S as CURRENCIES, T as CurrencyMismatchError, j as currencyCode, k as MINOR_UNIT_FACTOR, w as CurrencyCode } from "./money-CfAvfNiM.mjs";
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 };
@@ -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 };
@@ -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 };
@@ -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