@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.
@@ -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 };
@@ -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 };
@@ -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
@@ -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 };