@classytic/primitives 0.29.0 → 0.31.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,49 @@
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.31.0 — 2026-10-11
7
+
8
+ ### Added — `./quantity`: the 6-decimal quantity grid for fractional stock and sale quantities
9
+ - `snapQuantity(qty, step?, mode)` (half-even by default), `isOnStep`, `compareQuantity`, `quantitiesEqual`,
10
+ `quantityToGrid`, `quantityStepToGrid`, `QUANTITY_SCALE` (1,000,000) and `QuantityError`
11
+ (`NOT_FINITE` / `INVALID_STEP` / `UNSAFE_QUANTITY`).
12
+ - `isOnQuantityGrid(value)`: STRICT, for input boundaries. `isOnStep` snaps first, so
13
+ `isOnStep(2.3500004, 0.001)` is true; `isOnQuantityGrid(2.3500004)` is false.
14
+
15
+ ### Added — `./usage-pricing`
16
+ - `priceUsage(rating, measured)` prices a measured amount against a `UsageRating` (per-unit, tiered, volume,
17
+ package; a started unit counts whole; breakdown included), plus `UsageMeasure` / `isUsageMeasure` and
18
+ `UsagePricingError`.
19
+
20
+ ### Added — the stock unit group on item facts
21
+ - `uomGroupRef` (optional) on `ItemStockSource`, `ProductFacet` and `ItemStockFacts`, and `unitOfMeasureDeclared`
22
+ (optional) on `ProductFacet` and `ItemStockFacts`. `resolveStockFacts` takes the Item's group, falls back to
23
+ the product's only when no Item exists, and marks an ungrouped Item under a grouped product
24
+ `unitOfMeasureDeclared` instead of borrowing the product's group. All fields optional: existing values of these
25
+ types still type-check.
26
+
27
+ ### Changed — `resolvePriceBasisTotal` accepts a fractional quantity
28
+ - A whole quantity is computed exactly as before, across the full safe-integer range (pinned by a regression
29
+ test against the previous formula).
30
+ - A fractional quantity on the 6-decimal grid (e.g. `2.35`) is priced exactly: price × quantity on the grid,
31
+ divided by the measured basis, rounded ONCE half-even. Previously it threw `INVALID_QUANTITY`.
32
+ - Still refused (`INVALID_QUANTITY`): a negative, non-finite or off-grid quantity (a 7th decimal), or a
33
+ fraction too large to scale exactly.
34
+
35
+ ## 0.30.0 — never published separately; shipped in 0.31.0
36
+
37
+ ### Added — `./durable-events`: the declaration and the ONE kernel dispatch
38
+ - `EventDurability`, `DurableEventSpec`, `FactExpectation`; `eventIdFor` / `durableEventMeta` (`${name}:v${version}:${occurrence}`); `requireDurableContext` + `NonDurablePublishError`; `UnmanagedSessionError`.
39
+ - `dispatchDomainEvent(channels, unit, event, durability)` saves the outbox row in the session, defers to commit, and acknowledges after an inline publish.
40
+ - `flushDeferredEvents` delivers a committed queue and acknowledges every delivered row in ONE `acknowledgeMany`.
41
+ Pinned by `tests/unit/durable-events.test.ts`.
42
+
43
+ ### Added — `./outbox`: `OutboxConflictError`, `outboxPayloadFingerprint`, optional `OutboxStore.acknowledgeMany`
44
+
45
+ ### ⚠ Changed
46
+ - `createChildEvent` no longer inherits the parent's `idempotencyKey`. A child is its own occurrence; an inherited key made a store drop the second of two child types.
47
+ - `MemoryOutboxStore.save`: the same id with the same payload is a no-op, and a different payload throws `OutboxConflictError`.
48
+
6
49
  ## 0.29.0 — 2026-10-08
7
50
 
8
51
  ### Added — `./signed-token`: ES256 compact JWS over WebCrypto
@@ -32,7 +32,9 @@
32
32
  * isApproved(chain); // → true
33
33
  */
34
34
  type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'skipped';
35
- type ChainOrder = 'sequential' | 'parallel';
35
+ declare const CHAIN_ORDERS: readonly ["sequential", "parallel"];
36
+ type ChainOrder = (typeof CHAIN_ORDERS)[number];
37
+ declare const APPROVAL_THRESHOLD_OPS: readonly ["gt", "gte", "lt", "lte", "eq"];
36
38
  interface Approver {
37
39
  readonly id: string;
38
40
  readonly name?: string;
@@ -43,6 +45,11 @@ interface ApprovalDecision {
43
45
  readonly decision: 'approved' | 'rejected';
44
46
  readonly note?: string;
45
47
  readonly decidedAt: Date;
48
+ /**
49
+ * The org the approver acted from, when it differs from the document's (a cross-branch
50
+ * decision). The document's own org is the document's tenant field, so both are on the row.
51
+ */
52
+ readonly actorOrganizationId?: string;
46
53
  }
47
54
  /**
48
55
  * Optional threshold gate. The primitive does not evaluate this — it's
@@ -52,7 +59,7 @@ interface ApprovalDecision {
52
59
  */
53
60
  interface ApprovalThreshold {
54
61
  readonly field: string;
55
- readonly op: 'gt' | 'gte' | 'lt' | 'lte' | 'eq';
62
+ readonly op: (typeof APPROVAL_THRESHOLD_OPS)[number];
56
63
  readonly value: number;
57
64
  }
58
65
  interface ApprovalStep {
@@ -86,9 +93,13 @@ interface ApprovalChain {
86
93
  interface ApprovalRequest {
87
94
  readonly by: string;
88
95
  readonly at: Date;
89
- /** What the approval covers. A later amount above `basis.amount` is not covered. */
96
+ /**
97
+ * What the approval covers. A later amount above `basis.amount` is not covered. `unit` states the
98
+ * amount is integer base-currency minor units; a stored amount without it is of unknown unit.
99
+ */
90
100
  readonly basis?: {
91
101
  readonly amount?: number;
102
+ readonly unit?: 'minor';
92
103
  readonly category?: string;
93
104
  };
94
105
  }
@@ -178,15 +189,72 @@ declare function attachRequest(chain: ApprovalChain, request: ApprovalRequest):
178
189
  * For `parallel` chains: returns the first pending step (all pending steps
179
190
  * are active simultaneously; host typically iterates with {@link pendingSteps}).
180
191
  */
181
- declare function nextPendingStep(chain: ApprovalChain): ApprovalStep | null;
192
+ declare function nextPendingStep<S extends {
193
+ readonly status: ApprovalStatus;
194
+ }>(chain: {
195
+ readonly steps: readonly S[];
196
+ }): S | null;
182
197
  /** All steps currently eligible to receive a decision. */
183
- declare function pendingSteps(chain: ApprovalChain): readonly ApprovalStep[];
198
+ declare function pendingSteps<S extends {
199
+ readonly status: ApprovalStatus;
200
+ }>(chain: {
201
+ readonly order: ChainOrder;
202
+ readonly steps: readonly S[];
203
+ }): readonly S[];
204
+ /**
205
+ * The verbs every approval-bearing resource mounts at `POST /:id/action`.
206
+ * Servers (arc-approval) and clients (SDK, UI) both read these; never write the strings.
207
+ */
208
+ declare const APPROVAL_ACTIONS: {
209
+ readonly submit: "submit_for_approval";
210
+ readonly decide: "decide";
211
+ };
212
+ type ApprovalActionName = (typeof APPROVAL_ACTIONS)[keyof typeof APPROVAL_ACTIONS];
213
+ /** Where a submit sources its chain: inline `chain`, the policy matrix, or inferred. */
214
+ declare const APPROVAL_CHAIN_SOURCES: readonly ["auto", "literal", "policy"];
215
+ type ApprovalChainSource = (typeof APPROVAL_CHAIN_SOURCES)[number];
216
+ declare const APPROVAL_DECISIONS: readonly ["approved", "rejected"];
217
+ type ApprovalDecisionKind = (typeof APPROVAL_DECISIONS)[number];
218
+ /** Longest decision note the server accepts. */
219
+ declare const APPROVAL_NOTE_MAX_LENGTH = 1000;
220
+ /** `submit_for_approval` body. */
221
+ type ApprovalSubmitBody = {
222
+ readonly source?: ApprovalChainSource;
223
+ readonly chain?: CreateChainInput;
224
+ };
225
+ /** `decide` body. The approver is the authenticated actor — never a body field. */
226
+ type ApprovalDecideBody = {
227
+ readonly stepId: string;
228
+ readonly decision: ApprovalDecisionKind;
229
+ readonly note?: string;
230
+ };
231
+ /** The chain fields {@link decidableStepIds} reads — satisfied by a stored chain and by its JSON form. */
232
+ interface DecidableChain {
233
+ readonly order: ChainOrder;
234
+ readonly status: ApprovalStatus;
235
+ readonly steps: readonly {
236
+ readonly id: string;
237
+ readonly status: ApprovalStatus;
238
+ readonly approvers: readonly {
239
+ readonly id: string;
240
+ }[];
241
+ readonly decisions: readonly {
242
+ readonly approverId: string;
243
+ }[];
244
+ }[];
245
+ }
246
+ /**
247
+ * The steps `actorId` may decide now: active, naming the actor as an approver, not yet
248
+ * decided by them. The ONE answer to "may this actor decide" for server and UI alike.
249
+ */
250
+ declare function decidableStepIds(chain: DecidableChain, actorId: string | null | undefined): string[];
184
251
  interface DecisionInput {
185
252
  readonly stepId: string;
186
253
  readonly approverId: string;
187
254
  readonly decision: 'approved' | 'rejected';
188
255
  readonly note?: string;
189
256
  readonly decidedAt?: Date;
257
+ readonly actorOrganizationId?: string;
190
258
  }
191
259
  /**
192
260
  * Apply a decision to a step. Returns a new chain — original is not mutated.
@@ -266,4 +334,4 @@ declare function isPending(chain: ApprovalChain): boolean;
266
334
  */
267
335
  declare function decisionCount(chain: ApprovalChain): number;
268
336
  //#endregion
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 };
337
+ export { APPROVAL_ACTIONS, APPROVAL_CHAIN_SOURCES, APPROVAL_DECISIONS, APPROVAL_NOTE_MAX_LENGTH, APPROVAL_THRESHOLD_OPS, ApprovalActionName, ApprovalChain, ApprovalChainSource, ApprovalDecideBody, ApprovalDecision, ApprovalDecisionKind, ApprovalError, ApprovalErrorCode, ApprovalRequest, ApprovalStatus, ApprovalStep, ApprovalSubmitBody, ApprovalThreshold, Approver, AssertApprovedOptions, CHAIN_ORDERS, ChainOrder, CreateChainInput, CreateStepInput, DecidableChain, DecisionInput, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decidableStepIds, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
package/dist/approval.mjs CHANGED
@@ -1,4 +1,12 @@
1
1
  //#region src/workflow/approval.ts
2
+ const CHAIN_ORDERS = ["sequential", "parallel"];
3
+ const APPROVAL_THRESHOLD_OPS = [
4
+ "gt",
5
+ "gte",
6
+ "lt",
7
+ "lte",
8
+ "eq"
9
+ ];
2
10
  var ApprovalError = class extends Error {
3
11
  name = "ApprovalError";
4
12
  code;
@@ -125,6 +133,31 @@ function pendingSteps(chain) {
125
133
  return chain.steps.filter((s) => s.status === "pending");
126
134
  }
127
135
  /**
136
+ * The verbs every approval-bearing resource mounts at `POST /:id/action`.
137
+ * Servers (arc-approval) and clients (SDK, UI) both read these; never write the strings.
138
+ */
139
+ const APPROVAL_ACTIONS = {
140
+ submit: "submit_for_approval",
141
+ decide: "decide"
142
+ };
143
+ /** Where a submit sources its chain: inline `chain`, the policy matrix, or inferred. */
144
+ const APPROVAL_CHAIN_SOURCES = [
145
+ "auto",
146
+ "literal",
147
+ "policy"
148
+ ];
149
+ const APPROVAL_DECISIONS = ["approved", "rejected"];
150
+ /** Longest decision note the server accepts. */
151
+ const APPROVAL_NOTE_MAX_LENGTH = 1e3;
152
+ /**
153
+ * The steps `actorId` may decide now: active, naming the actor as an approver, not yet
154
+ * decided by them. The ONE answer to "may this actor decide" for server and UI alike.
155
+ */
156
+ function decidableStepIds(chain, actorId) {
157
+ if (!actorId || chain.status !== "pending") return [];
158
+ return pendingSteps(chain).filter((s) => s.approvers.some((a) => a.id === actorId) && !s.decisions.some((d) => d.approverId === actorId)).map((s) => s.id);
159
+ }
160
+ /**
128
161
  * Apply a decision to a step. Returns a new chain — original is not mutated.
129
162
  * Throws if the step is unknown, not active, the approver isn't listed, or
130
163
  * the approver has already decided.
@@ -142,7 +175,8 @@ function applyDecision(chain, input) {
142
175
  approverId: input.approverId,
143
176
  decision: input.decision,
144
177
  ...input.note !== void 0 ? { note: input.note } : {},
145
- decidedAt: input.decidedAt ?? /* @__PURE__ */ new Date()
178
+ decidedAt: input.decidedAt ?? /* @__PURE__ */ new Date(),
179
+ ...input.actorOrganizationId !== void 0 ? { actorOrganizationId: input.actorOrganizationId } : {}
146
180
  };
147
181
  const updatedStep = computeStepStatus({
148
182
  ...step,
@@ -271,4 +305,4 @@ function computeChainStatus(steps) {
271
305
  return steps.every((s) => s.status === "approved" || s.status === "skipped") ? "approved" : "pending";
272
306
  }
273
307
  //#endregion
274
- export { ApprovalError, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
308
+ export { APPROVAL_ACTIONS, APPROVAL_CHAIN_SOURCES, APPROVAL_DECISIONS, APPROVAL_NOTE_MAX_LENGTH, APPROVAL_THRESHOLD_OPS, ApprovalError, CHAIN_ORDERS, applyDecision, assertApproved, assertApprovedIfPresent, attachRequest, createChain, decidableStepIds, decisionCount, isApproved, isNotRequired, isPending, isRejected, nextPendingStep, notRequiredChain, pendingSteps, skipStep };
@@ -0,0 +1,83 @@
1
+ import { DomainEvent, EventMeta, EventTransport } from "./events.mjs";
2
+ import { ErrorLogger } from "./event-infra.mjs";
3
+ import { OutboxStore } from "./outbox.mjs";
4
+ //#region src/events/durable.d.ts
5
+ /** Required on every definition; no default, so neither side escapes by omission. */
6
+ type EventDurability = 'ephemeral' | 'durable';
7
+ /**
8
+ * A fact a durable event must eventually produce — attached at composition by its CONSUMER.
9
+ * Members are methods so a typed definition stays assignable to an `unknown`-payload registry.
10
+ */
11
+ interface FactExpectation<P> {
12
+ /** `'journal-entry'`, `'stock-move'`, … — matched to a mounted probe. */
13
+ readonly kind: string;
14
+ /** The consumer's own key function: the keys its fact rows carry for this event. */
15
+ keys(payload: P, meta: EventMeta): readonly string[];
16
+ readonly graceMinutes: number;
17
+ /** `'unknown'` is a finding, never "does not apply". */
18
+ applies?(payload: P): boolean | 'unknown';
19
+ }
20
+ type DurableEventSpec<P> = {
21
+ readonly durability: 'ephemeral';
22
+ } | {
23
+ readonly durability: 'durable';
24
+ /** Aggregate id plus step (`fulfillmentId`, `${paymentId}:captured`) — never the order alone. */
25
+ occurrence(payload: P): string;
26
+ readonly produces?: readonly FactExpectation<P>[];
27
+ };
28
+ /** Where a publish happens. `deliveryOf` is the parent event id, set around a relayed delivery. */
29
+ interface DurableContext {
30
+ readonly session?: unknown;
31
+ readonly outbox?: OutboxStore | undefined;
32
+ readonly deliveryOf?: string | undefined;
33
+ }
34
+ declare class NonDurablePublishError extends Error {
35
+ readonly name = "NonDurablePublishError";
36
+ readonly code = "events.non_durable_publish";
37
+ readonly eventType: string;
38
+ constructor(eventType: string, reason: string);
39
+ }
40
+ /** An open session with neither an outbox nor a post-commit queue: publishing leaks, dropping loses. */
41
+ declare class UnmanagedSessionError extends Error {
42
+ readonly name = "UnmanagedSessionError";
43
+ readonly code = "events.unmanaged_session";
44
+ constructor(eventType: string);
45
+ }
46
+ /** `${name}:v${version}:${occurrence}` — the same occurrence always yields the same id. */
47
+ declare function eventIdFor(name: string, version: number, occurrence: string): string;
48
+ /** The meta a durable event is created with: a fixed id, and the same value as its idempotency key. */
49
+ declare function durableEventMeta(name: string, version: number, occurrence: string): Required<Pick<EventMeta, 'id' | 'idempotencyKey'>>;
50
+ /** Throws unless a durable type is in a unit of work (session + transactional store) or a durable delivery. */
51
+ declare function requireDurableContext(def: {
52
+ readonly name: string;
53
+ readonly durability: EventDurability;
54
+ }, ctx: DurableContext): void;
55
+ interface DispatchChannels {
56
+ readonly outbox: OutboxStore | undefined;
57
+ readonly transport: EventTransport | undefined;
58
+ /** With an outbox: `'relay'` = the row is the delivery; `'inline'` (default) = publish live, then acknowledge. */
59
+ readonly dispatch?: 'inline' | 'relay' | undefined;
60
+ /** Prefix for log lines, e.g. the kernel name. */
61
+ readonly label: string;
62
+ readonly logger?: ErrorLogger | undefined;
63
+ }
64
+ interface DispatchUnit extends Omit<DurableContext, 'outbox'> {
65
+ /** The post-commit queue of an open unit of work; its owner calls {@link flushDeferredEvents}. */
66
+ readonly pending?: DomainEvent[] | undefined;
67
+ }
68
+ /** `published` and `relay` are delivered or owned; `publish_failed` leaves a pending row (or nothing, with no outbox). */
69
+ type DispatchOutcome = 'deferred' | 'committed' | 'relay' | 'published' | 'publish_failed' | 'no_transport';
70
+ /**
71
+ * Emit one domain event. The outbox save runs first, in the session, and its failure propagates.
72
+ * In a unit of work delivery waits for {@link flushDeferredEvents}; otherwise an inline publish is
73
+ * acknowledged so the relay does not deliver it again, and a failed one stays pending for the relay.
74
+ */
75
+ declare function dispatchDomainEvent(channels: DispatchChannels, unit: DispatchUnit, event: DomainEvent, durability: EventDurability): Promise<DispatchOutcome>;
76
+ /**
77
+ * Deliver a committed unit of work's queue. Under `'relay'` the rows are the delivery and nothing
78
+ * is published. Returns each published event's outcome (`null` = delivered and acknowledged).
79
+ * Delivered rows are acknowledged with one `acknowledgeMany` when the store has it.
80
+ */
81
+ declare function flushDeferredEvents(channels: DispatchChannels, queue: DomainEvent[]): Promise<ReadonlyMap<string, Error | null>>;
82
+ //#endregion
83
+ export { DispatchChannels, DispatchOutcome, DispatchUnit, DurableContext, DurableEventSpec, EventDurability, FactExpectation, NonDurablePublishError, UnmanagedSessionError, dispatchDomainEvent, durableEventMeta, eventIdFor, flushDeferredEvents, requireDurableContext };
@@ -0,0 +1,115 @@
1
+ //#region src/events/durable.ts
2
+ var NonDurablePublishError = class extends Error {
3
+ name = "NonDurablePublishError";
4
+ code = "events.non_durable_publish";
5
+ eventType;
6
+ constructor(eventType, reason) {
7
+ super(`Durable event "${eventType}" cannot be published here: ${reason}.`);
8
+ this.eventType = eventType;
9
+ }
10
+ };
11
+ /** An open session with neither an outbox nor a post-commit queue: publishing leaks, dropping loses. */
12
+ var UnmanagedSessionError = class extends Error {
13
+ name = "UnmanagedSessionError";
14
+ code = "events.unmanaged_session";
15
+ constructor(eventType) {
16
+ super(`Event "${eventType}" was emitted inside a caller-owned session with no outbox and no post-commit queue. Wire an outbox or run the write in the engine unit of work.`);
17
+ }
18
+ };
19
+ /** `${name}:v${version}:${occurrence}` — the same occurrence always yields the same id. */
20
+ function eventIdFor(name, version, occurrence) {
21
+ if (typeof name !== "string" || name.length === 0) throw new TypeError("eventIdFor: event name must be a non-empty string");
22
+ if (!Number.isInteger(version) || version < 1) throw new TypeError(`eventIdFor(${name}): version must be a positive integer, got ${String(version)}`);
23
+ if (typeof occurrence !== "string" || occurrence.length === 0) throw new TypeError(`eventIdFor(${name}): occurrence must be a non-empty string, got ${typeof occurrence === "string" ? "\"\"" : typeof occurrence}`);
24
+ return `${name}:v${version}:${occurrence}`;
25
+ }
26
+ /** The meta a durable event is created with: a fixed id, and the same value as its idempotency key. */
27
+ function durableEventMeta(name, version, occurrence) {
28
+ const id = eventIdFor(name, version, occurrence);
29
+ return {
30
+ id,
31
+ idempotencyKey: id
32
+ };
33
+ }
34
+ /** Throws unless a durable type is in a unit of work (session + transactional store) or a durable delivery. */
35
+ function requireDurableContext(def, ctx) {
36
+ if (def.durability === "ephemeral") return;
37
+ if (!ctx.outbox) throw new NonDurablePublishError(def.name, "no outbox is wired");
38
+ if (ctx.deliveryOf !== void 0) return;
39
+ if (ctx.session === void 0) throw new NonDurablePublishError(def.name, "no unit of work is open (no session) and this is not a durable delivery");
40
+ if (ctx.outbox.transactionalSave !== true) throw new NonDurablePublishError(def.name, "the outbox store does not declare transactionalSave");
41
+ }
42
+ /**
43
+ * Emit one domain event. The outbox save runs first, in the session, and its failure propagates.
44
+ * In a unit of work delivery waits for {@link flushDeferredEvents}; otherwise an inline publish is
45
+ * acknowledged so the relay does not deliver it again, and a failed one stays pending for the relay.
46
+ */
47
+ async function dispatchDomainEvent(channels, unit, event, durability) {
48
+ requireDurableContext({
49
+ name: event.type,
50
+ durability
51
+ }, {
52
+ session: unit.session,
53
+ outbox: channels.outbox,
54
+ deliveryOf: unit.deliveryOf
55
+ });
56
+ const log = channels.logger ?? console;
57
+ if (channels.outbox) await channels.outbox.save(event, unit.session === void 0 ? {} : { session: unit.session });
58
+ else if (unit.session !== void 0 && !unit.pending) throw new UnmanagedSessionError(event.type);
59
+ if (unit.pending) {
60
+ unit.pending.push(event);
61
+ return "deferred";
62
+ }
63
+ if (unit.session !== void 0) return "committed";
64
+ if (channels.outbox && channels.dispatch === "relay") return "relay";
65
+ if (!channels.transport) return "no_transport";
66
+ try {
67
+ await channels.transport.publish(event);
68
+ } catch (err) {
69
+ log.error(`[${channels.label}] publish failed for ${event.type} ${event.meta.id}:`, err);
70
+ return "publish_failed";
71
+ }
72
+ await acknowledgeDelivered(channels, event, log);
73
+ return "published";
74
+ }
75
+ /**
76
+ * Deliver a committed unit of work's queue. Under `'relay'` the rows are the delivery and nothing
77
+ * is published. Returns each published event's outcome (`null` = delivered and acknowledged).
78
+ * Delivered rows are acknowledged with one `acknowledgeMany` when the store has it.
79
+ */
80
+ async function flushDeferredEvents(channels, queue) {
81
+ const outcomes = /* @__PURE__ */ new Map();
82
+ const events = queue.splice(0);
83
+ if (channels.outbox && channels.dispatch === "relay" || !channels.transport) return outcomes;
84
+ const log = channels.logger ?? console;
85
+ for (const event of events) {
86
+ try {
87
+ await channels.transport.publish(event);
88
+ } catch (err) {
89
+ outcomes.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
90
+ log.error(`[${channels.label}] publish failed for ${event.type} ${event.meta.id}:`, err);
91
+ continue;
92
+ }
93
+ outcomes.set(event.meta.id, null);
94
+ }
95
+ const delivered = events.filter((e) => outcomes.get(e.meta.id) === null);
96
+ const outbox = channels.outbox;
97
+ if (outbox?.acknowledgeMany && delivered.length > 0) try {
98
+ await outbox.acknowledgeMany(delivered.map((e) => e.meta.id));
99
+ } catch (err) {
100
+ log.error(`[${channels.label}] outbox acknowledgeMany failed for ${delivered.length} events:`, err);
101
+ }
102
+ else for (const event of delivered) await acknowledgeDelivered(channels, event, log);
103
+ return outcomes;
104
+ }
105
+ /** A failed acknowledge only risks a redelivery, which consumers absorb by id; it never fails the caller. */
106
+ async function acknowledgeDelivered(channels, event, log) {
107
+ if (!channels.outbox) return;
108
+ try {
109
+ await channels.outbox.acknowledge(event.meta.id);
110
+ } catch (err) {
111
+ log.error(`[${channels.label}] outbox acknowledge failed for ${event.type} ${event.meta.id}:`, err);
112
+ }
113
+ }
114
+ //#endregion
115
+ export { NonDurablePublishError, UnmanagedSessionError, dispatchDomainEvent, durableEventMeta, eventIdFor, flushDeferredEvents, requireDurableContext };
package/dist/events.d.mts CHANGED
@@ -86,7 +86,7 @@ interface EventMeta {
86
86
  * `meta.id` (which is fresh per emit — a retry would produce a new id).
87
87
  *
88
88
  * Typical sources: HTTP `Idempotency-Key` header, outbox `dedupeKey`, or
89
- * `{aggregate.type}:{aggregate.id}:{action}`. Inherited by child events.
89
+ * `{aggregate.type}:{aggregate.id}:{action}`. NOT inherited by child events.
90
90
  */
91
91
  idempotencyKey?: string;
92
92
  /**
@@ -215,11 +215,11 @@ declare function createEvent<T>(type: string, payload: T, meta?: Partial<EventMe
215
215
  * - `causationId` is set to the parent's `id` (direct cause)
216
216
  * - `correlationId` is inherited from the parent if set, else falls back
217
217
  * to the parent's `id` (root correlation)
218
- * - `userId` / `organizationId` / `source` / `idempotencyKey` are inherited
219
- * when set on the parent so the whole chain stays scoped to the
220
- * originating principal, tenant, emitter, and logical operation
221
- * - `aggregate` is NOT inherited — child events typically belong to a
222
- * different aggregate than their parent
218
+ * - `userId` / `organizationId` / `source` are inherited when set on the
219
+ * parent so the chain stays scoped to the originating principal, tenant
220
+ * and emitter
221
+ * - `idempotencyKey` and `aggregate` are NOT inherited — a child is its own
222
+ * occurrence (see `@classytic/primitives/durable-events` `eventIdFor`)
223
223
  *
224
224
  * Caller-supplied `meta` wins over inherited fields — pass `{ userId: newActor }`
225
225
  * to override when a subsystem acts on behalf of a different principal.
package/dist/events.mjs CHANGED
@@ -22,11 +22,11 @@ function createEvent(type, payload, meta) {
22
22
  * - `causationId` is set to the parent's `id` (direct cause)
23
23
  * - `correlationId` is inherited from the parent if set, else falls back
24
24
  * to the parent's `id` (root correlation)
25
- * - `userId` / `organizationId` / `source` / `idempotencyKey` are inherited
26
- * when set on the parent so the whole chain stays scoped to the
27
- * originating principal, tenant, emitter, and logical operation
28
- * - `aggregate` is NOT inherited — child events typically belong to a
29
- * different aggregate than their parent
25
+ * - `userId` / `organizationId` / `source` are inherited when set on the
26
+ * parent so the chain stays scoped to the originating principal, tenant
27
+ * and emitter
28
+ * - `idempotencyKey` and `aggregate` are NOT inherited — a child is its own
29
+ * occurrence (see `@classytic/primitives/durable-events` `eventIdFor`)
30
30
  *
31
31
  * Caller-supplied `meta` wins over inherited fields — pass `{ userId: newActor }`
32
32
  * to override when a subsystem acts on behalf of a different principal.
@@ -51,7 +51,6 @@ function createChildEvent(parent, type, payload, meta) {
51
51
  if (parent.meta.userId !== void 0) inherited.userId = parent.meta.userId;
52
52
  if (parent.meta.organizationId !== void 0) inherited.organizationId = parent.meta.organizationId;
53
53
  if (parent.meta.source !== void 0) inherited.source = parent.meta.source;
54
- if (parent.meta.idempotencyKey !== void 0) inherited.idempotencyKey = parent.meta.idempotencyKey;
55
54
  return {
56
55
  type,
57
56
  payload,
@@ -9,6 +9,8 @@ interface ItemStockSource {
9
9
  displayName: string;
10
10
  /** The canonical STOCK unit — what quants and moves are balanced in. */
11
11
  uom: string;
12
+ /** The stock unit's UoM group (`_id` or `code`); its base unit is `uom` and its rounding is the posting step. */
13
+ uomGroupRef?: string | undefined;
12
14
  trackingMode: TrackingMode;
13
15
  trackingPolicy?: ShelfLifePolicy | undefined;
14
16
  /**
@@ -29,6 +31,10 @@ interface ProductFacet {
29
31
  sku: string;
30
32
  displayName: string;
31
33
  uom?: string | undefined;
34
+ /** Fallback group for a product-only deployment; never read when an Item exists. */
35
+ uomGroupRef?: string | undefined;
36
+ /** The product declares a unit of measure, even if its group was lost on the way here. */
37
+ unitOfMeasureDeclared?: boolean | undefined;
32
38
  trackingMode?: TrackingMode | undefined;
33
39
  trackingPolicy?: ShelfLifePolicy | undefined;
34
40
  catchWeight?: boolean | undefined;
@@ -46,6 +52,14 @@ interface ItemStockFacts {
46
52
  trackingMode: TrackingMode;
47
53
  trackingPolicy?: ShelfLifePolicy;
48
54
  uom: string;
55
+ /** UoM group whose rounding a posting snaps to. Absent ⇒ the bare quantity grid. */
56
+ uomGroupRef?: string;
57
+ /**
58
+ * A source declared a unit of measure, so a missing `uomGroupRef` must be
59
+ * REFUSED by the stock engine, never read as "no rounding". Not set by `uom`
60
+ * alone: every Item has a `uom`.
61
+ */
62
+ unitOfMeasureDeclared?: boolean;
49
63
  catchWeight?: boolean;
50
64
  weightUom?: string;
51
65
  weight?: number;
@@ -64,6 +78,11 @@ interface ItemStockFacts {
64
78
  * `isActive` follows the ITEM when one exists: a product withdrawn from sale is
65
79
  * still stock on a shelf, whereas a retired item is not stockable at all. Taking
66
80
  * the product's flag would make unlisting a product silently orphan its stock.
81
+ *
82
+ * `uomGroupRef` follows the ITEM when one exists. A product group under an Item
83
+ * that declares none is never borrowed (its step is in a unit the Item may not
84
+ * count in): the facts carry `unitOfMeasureDeclared: true` and no group, so the
85
+ * stock engine refuses the posting until the Item declares its own.
67
86
  */
68
87
  declare function resolveStockFacts(input: {
69
88
  item?: ItemStockSource | null | undefined;
@@ -2,6 +2,7 @@
2
2
  /** Unit assumed when neither side declares one. A deployment-wide guess, and a bad one — set a real `uom` on the Item. */
3
3
  const FALLBACK_UOM = "unit";
4
4
  const defined = (key, value) => value === void 0 ? {} : { [key]: value };
5
+ const declaresGroup = (p) => !!p && (p.uomGroupRef !== void 0 || p.unitOfMeasureDeclared === true);
5
6
  /**
6
7
  * Merge an Item with its Product facet.
7
8
  *
@@ -13,6 +14,11 @@ const defined = (key, value) => value === void 0 ? {} : { [key]: value };
13
14
  * `isActive` follows the ITEM when one exists: a product withdrawn from sale is
14
15
  * still stock on a shelf, whereas a retired item is not stockable at all. Taking
15
16
  * the product's flag would make unlisting a product silently orphan its stock.
17
+ *
18
+ * `uomGroupRef` follows the ITEM when one exists. A product group under an Item
19
+ * that declares none is never borrowed (its step is in a unit the Item may not
20
+ * count in): the facts carry `unitOfMeasureDeclared: true` and no group, so the
21
+ * stock engine refuses the posting until the Item declares its own.
16
22
  */
17
23
  function resolveStockFacts(input) {
18
24
  const { item, product } = input;
@@ -24,6 +30,8 @@ function resolveStockFacts(input) {
24
30
  sku: p.sku,
25
31
  displayName: p.displayName,
26
32
  uom: p.uom ?? FALLBACK_UOM,
33
+ ...defined("uomGroupRef", p.uomGroupRef),
34
+ ...p.unitOfMeasureDeclared === true ? { unitOfMeasureDeclared: true } : {},
27
35
  trackingMode: p.trackingMode ?? "none",
28
36
  ...defined("trackingPolicy", p.trackingPolicy),
29
37
  ...defined("catchWeight", p.catchWeight),
@@ -39,6 +47,8 @@ function resolveStockFacts(input) {
39
47
  sku: product?.sku ?? item.sku,
40
48
  displayName: product?.displayName ?? item.displayName,
41
49
  uom: item.uom,
50
+ ...defined("uomGroupRef", item.uomGroupRef),
51
+ ...item.uomGroupRef === void 0 && declaresGroup(product) ? { unitOfMeasureDeclared: true } : {},
42
52
  trackingMode: item.trackingMode,
43
53
  ...defined("trackingPolicy", item.trackingPolicy),
44
54
  ...defined("catchWeight", item.catchWeight),
@@ -48,6 +48,7 @@ declare class MemoryOutboxStore implements OutboxStore {
48
48
  claimPending(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
49
49
  claimPendingFenced(options?: OutboxClaimOptions): Promise<OutboxClaimedEvent[]>;
50
50
  acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
51
+ acknowledgeMany(eventIds: readonly string[]): Promise<void>;
51
52
  fail(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
52
53
  getDeadLettered(limit: number): Promise<DeadLetteredEvent[]>;
53
54
  purge(olderThanMs: number): Promise<number>;
@@ -1,4 +1,4 @@
1
- import { InvalidOutboxEventError, OutboxOwnershipError } from "./outbox.mjs";
1
+ import { InvalidOutboxEventError, OutboxConflictError, OutboxOwnershipError, outboxPayloadFingerprint } from "./outbox.mjs";
2
2
  //#region src/events/memory-outbox.ts
3
3
  const DEFAULT_LEASE_MS = 3e4;
4
4
  var MemoryOutboxStore = class {
@@ -29,6 +29,11 @@ var MemoryOutboxStore = class {
29
29
  async save(event, options) {
30
30
  if (!event?.type || typeof event.type !== "string") throw new InvalidOutboxEventError("event.type is required");
31
31
  if (!event.meta?.id || typeof event.meta.id !== "string") throw new InvalidOutboxEventError("event.meta.id is required");
32
+ const existing = this.entries.find((e) => e.event.meta.id === event.meta.id);
33
+ if (existing) {
34
+ if (outboxPayloadFingerprint(existing.event) === outboxPayloadFingerprint(event)) return;
35
+ throw new OutboxConflictError(event.meta.id, event.type);
36
+ }
32
37
  if (options?.dedupeKey) {
33
38
  if (this.seenDedupeKeys.has(options.dedupeKey)) return;
34
39
  this.seenDedupeKeys.add(options.dedupeKey);
@@ -90,6 +95,16 @@ var MemoryOutboxStore = class {
90
95
  entry.deliveredAt = Date.now();
91
96
  entry.leaseOwner = null;
92
97
  }
98
+ async acknowledgeMany(eventIds) {
99
+ const ids = new Set(eventIds);
100
+ const now = Date.now();
101
+ for (const entry of this.entries) {
102
+ if (!ids.has(entry.event.meta.id) || entry.status === "delivered") continue;
103
+ entry.status = "delivered";
104
+ entry.deliveredAt = now;
105
+ entry.leaseOwner = null;
106
+ }
107
+ }
93
108
  async fail(eventId, error, options) {
94
109
  const entry = this.entries.find((e) => e.event.meta.id === eventId);
95
110
  if (!entry) return;
@@ -297,6 +297,8 @@ declare function unitPriceOf(m: Monetization): Money | null;
297
297
  * `defaultPassOf` picked.
298
298
  */
299
299
  declare function compareAtPriceOf(m: Monetization): Money | null;
300
+ /** On sale: a compare-at price above the selling price, same currency. The ONE on-sale predicate. */
301
+ declare function isOnSale(m: Monetization): boolean;
300
302
  /**
301
303
  * `@classytic/revenue`'s coarse wire enum. `purchase` is the paid-once case that
302
304
  * the rest of the ecosystem calls `one_time`. Kept as its own type so revenue's
@@ -316,4 +318,4 @@ declare function toRevenueMonetizationType(kind: MonetizationKind): RevenueMonet
316
318
  /** Revenue wire type → canonical kind (`purchase` → `one_time`). */
317
319
  declare function fromRevenueMonetizationType(type: RevenueMonetizationType): MonetizationKind;
318
320
  //#endregion
319
- export { AccessPass, BundleMonetization, DurationUnit, FreeMonetization, MONETIZATION_KINDS, Monetization, MonetizationKind, MoneyByCurrency, OneTimeMonetization, OneTimePricing, PassMonetization, PriceTier, RevenueMonetizationType, SubscriptionMonetization, SubscriptionPlan, UPFRONT_COLLECTION_MODES, UpfrontCollectionMode, UsageMonetization, UsageRating, UsageTier, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
321
+ export { AccessPass, BundleMonetization, DurationUnit, FreeMonetization, MONETIZATION_KINDS, Monetization, MonetizationKind, MoneyByCurrency, OneTimeMonetization, OneTimePricing, PassMonetization, PriceTier, RevenueMonetizationType, SubscriptionMonetization, SubscriptionPlan, UPFRONT_COLLECTION_MODES, UpfrontCollectionMode, UsageMonetization, UsageRating, UsageTier, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOnSale, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
@@ -164,6 +164,12 @@ function compareAtPriceOf(m) {
164
164
  if (m.type === "pass") return defaultPassOf(m)?.compareAtPrice ?? null;
165
165
  return null;
166
166
  }
167
+ /** On sale: a compare-at price above the selling price, same currency. The ONE on-sale predicate. */
168
+ function isOnSale(m) {
169
+ const compareAt = compareAtPriceOf(m);
170
+ const price = unitPriceOf(m);
171
+ return compareAt !== null && price !== null && compareAt.currency === price.currency && compareAt.amount > price.amount;
172
+ }
167
173
  /**
168
174
  * Canonical kind → revenue wire type. Everything that is not `free` or
169
175
  * `subscription` settles as a `purchase` in revenue's ledger vocabulary
@@ -184,4 +190,4 @@ function fromRevenueMonetizationType(type) {
184
190
  return "one_time";
185
191
  }
186
192
  //#endregion
187
- export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };
193
+ export { MONETIZATION_KINDS, UPFRONT_COLLECTION_MODES, approxTermDays, compareAtPriceOf, defaultPassOf, fromRevenueMonetizationType, isBundleMonetization, isFreeMonetization, isMonetizationKind, isOnSale, isOneTimeMonetization, isPassMonetization, isSubscriptionMonetization, isUpfrontCollectionMode, isUsageMonetization, monetizationKindOf, toRevenueMonetizationType, unitPriceOf };