@classytic/primitives 0.29.0 → 0.30.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,20 @@
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.30.0 — 2026-10-11
7
+
8
+ ### Added — `./durable-events`: the declaration and the ONE kernel dispatch
9
+ - `EventDurability`, `DurableEventSpec`, `FactExpectation`; `eventIdFor` / `durableEventMeta` (`${name}:v${version}:${occurrence}`); `requireDurableContext` + `NonDurablePublishError`; `UnmanagedSessionError`.
10
+ - `dispatchDomainEvent(channels, unit, event, durability)` saves the outbox row in the session, defers to commit, and acknowledges after an inline publish.
11
+ - `flushDeferredEvents` delivers a committed queue and acknowledges every delivered row in ONE `acknowledgeMany`.
12
+ Pinned by `tests/unit/durable-events.test.ts`.
13
+
14
+ ### Added — `./outbox`: `OutboxConflictError`, `outboxPayloadFingerprint`, optional `OutboxStore.acknowledgeMany`
15
+
16
+ ### ⚠ Changed
17
+ - `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.
18
+ - `MemoryOutboxStore.save`: the same id with the same payload is a no-op, and a different payload throws `OutboxConflictError`.
19
+
6
20
  ## 0.29.0 — 2026-10-08
7
21
 
8
22
  ### 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,
@@ -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 };
package/dist/outbox.d.mts CHANGED
@@ -141,6 +141,23 @@ declare class OutboxOwnershipError extends Error {
141
141
  readonly currentOwner: string | null;
142
142
  constructor(eventId: string, attemptedBy: string, currentOwner: string | null);
143
143
  }
144
+ /**
145
+ * Thrown by `save` when a row with this event id already exists with a DIFFERENT payload.
146
+ * A same-id, same-payload save is an idempotent success; a different payload is two
147
+ * occurrences minted onto one id, and dropping either one loses money silently.
148
+ */
149
+ declare class OutboxConflictError extends Error {
150
+ readonly name = "OutboxConflictError";
151
+ readonly code = "outbox.conflict";
152
+ readonly eventId: string;
153
+ readonly eventType: string;
154
+ constructor(eventId: string, eventType: string);
155
+ }
156
+ /**
157
+ * Order-independent fingerprint of an event payload as it travels (JSON: `Date` becomes its
158
+ * ISO string, `undefined` drops). Stores compare it on a duplicate id; see {@link OutboxConflictError}.
159
+ */
160
+ declare function outboxPayloadFingerprint(event: DomainEvent): string;
144
161
  /** Thrown when an event missing `type` or `meta.id` is passed to the outbox. */
145
162
  declare class InvalidOutboxEventError extends Error {
146
163
  readonly name = "InvalidOutboxEventError";
@@ -235,7 +252,8 @@ interface OutboxStore {
235
252
  /**
236
253
  * Save an event to the outbox (typically called inside a business
237
254
  * transaction). MUST reject events missing `type` or `meta.id` — throw
238
- * rather than persist.
255
+ * rather than persist. A second save of an existing `meta.id` is a no-op when
256
+ * the payload matches, and throws {@link OutboxConflictError} when it does not.
239
257
  */
240
258
  save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
241
259
  /**
@@ -267,6 +285,12 @@ interface OutboxStore {
267
285
  * @throws {@link OutboxOwnershipError} on ownership mismatch
268
286
  */
269
287
  acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
288
+ /**
289
+ * Mark every listed event delivered in ONE write — what a unit of work's post-commit flush
290
+ * calls, so N delivered events cost one command, not N. Unleased rows only (no fencing);
291
+ * unknown or already-delivered ids are ignored.
292
+ */
293
+ acknowledgeMany?(eventIds: readonly string[]): Promise<void>;
270
294
  /**
271
295
  * Record a relay failure. When implemented, the relay calls this instead
272
296
  * of stopping the batch — enables retry scheduling and DLQ.
@@ -335,4 +359,4 @@ interface OutboxStore {
335
359
  oldestPendingAgeMs?(): Promise<number | null>;
336
360
  }
337
361
  //#endregion
338
- export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStatus, OutboxStore, OutboxWriteOptions };
362
+ export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxClaimedEvent, OutboxConflictError, OutboxErrorInfo, OutboxFailOptions, OutboxFailureContext, OutboxFailureDecision, OutboxFailurePolicy, OutboxOwnershipError, OutboxStatus, OutboxStore, OutboxWriteOptions, outboxPayloadFingerprint };
package/dist/outbox.mjs CHANGED
@@ -1,5 +1,23 @@
1
+ import { canonicalDigest } from "./canonical.mjs";
1
2
  //#region src/events/outbox.ts
2
3
  /**
4
+ * Transactional outbox CONTRACT — the canonical owner. `@classytic/arc`
5
+ * re-exports this; it keeps no copy of its own.
6
+ *
7
+ * Contract surface only: {@link OutboxStore}, its option types, the
8
+ * {@link OutboxFailurePolicy} retry/DLQ contract, and the error classes.
9
+ *
10
+ * The split exists because a domain package MUST NOT peer-dep arc. Owning the
11
+ * contract here lets a package write `implements OutboxStore` and hand the
12
+ * result to arc's `new EventOutbox({ store })`, and since arc re-exports these
13
+ * exact classes, `instanceof OutboxOwnershipError` works across the boundary.
14
+ *
15
+ * Runtimes live elsewhere: the DURABLE one (`EventOutbox` relay, Mongo store,
16
+ * backoff, Fastify wiring) in `@classytic/arc/events`; the dependency-free
17
+ * in-process reference (kernel bus + memory store, for tests/dev) in
18
+ * `@classytic/primitives/event-infra`.
19
+ */
20
+ /**
3
21
  * Thrown by a store when `acknowledge` / `fail` is called by a consumer that
4
22
  * does not own the event's current lease.
5
23
  *
@@ -19,6 +37,30 @@ var OutboxOwnershipError = class extends Error {
19
37
  this.currentOwner = currentOwner;
20
38
  }
21
39
  };
40
+ /**
41
+ * Thrown by `save` when a row with this event id already exists with a DIFFERENT payload.
42
+ * A same-id, same-payload save is an idempotent success; a different payload is two
43
+ * occurrences minted onto one id, and dropping either one loses money silently.
44
+ */
45
+ var OutboxConflictError = class extends Error {
46
+ name = "OutboxConflictError";
47
+ code = "outbox.conflict";
48
+ eventId;
49
+ eventType;
50
+ constructor(eventId, eventType) {
51
+ super(`Outbox already holds event "${eventId}" (${eventType}) with a different payload — two occurrences share one id. Derive the occurrence from the aggregate id plus the step.`);
52
+ this.eventId = eventId;
53
+ this.eventType = eventType;
54
+ }
55
+ };
56
+ /**
57
+ * Order-independent fingerprint of an event payload as it travels (JSON: `Date` becomes its
58
+ * ISO string, `undefined` drops). Stores compare it on a duplicate id; see {@link OutboxConflictError}.
59
+ */
60
+ function outboxPayloadFingerprint(event) {
61
+ const wire = JSON.parse(JSON.stringify(event.payload ?? null));
62
+ return canonicalDigest(wire);
63
+ }
22
64
  /** Thrown when an event missing `type` or `meta.id` is passed to the outbox. */
23
65
  var InvalidOutboxEventError = class extends Error {
24
66
  name = "InvalidOutboxEventError";
@@ -90,4 +132,4 @@ const OUTBOX_STATUSES = [
90
132
  "dead_letter"
91
133
  ];
92
134
  //#endregion
93
- export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxOwnershipError };
135
+ export { InvalidOutboxEventError, OUTBOX_STATUSES, OutboxConflictError, OutboxOwnershipError, outboxPayloadFingerprint };
@@ -60,7 +60,7 @@ interface SplitResult {
60
60
  readonly method: SplitMethod;
61
61
  readonly parts: readonly SplitPart[];
62
62
  }
63
- type SplitAllocationErrorCode = 'INVALID_TOTAL' | 'EMPTY_SUBJECTS' | 'INVALID_WEIGHT' | 'INVALID_PERCENT' | 'ZERO_WEIGHT_SUM' | 'DUPLICATE_SUBJECT_ID';
63
+ type SplitAllocationErrorCode = 'INVALID_TOTAL' | 'EMPTY_SUBJECTS' | 'INVALID_WEIGHT' | 'INVALID_PERCENT' | 'ZERO_WEIGHT_SUM' | 'DUPLICATE_SUBJECT_ID' | 'DISCOUNT_EXCEEDS_BASE';
64
64
  declare class SplitAllocationError extends Error {
65
65
  readonly name = "SplitAllocationError";
66
66
  readonly code: SplitAllocationErrorCode;
@@ -80,5 +80,23 @@ declare class SplitAllocationError extends Error {
80
80
  declare function allocate(total: number, subjects: readonly SplitSubject[], method: SplitMethod): SplitResult;
81
81
  /** True if `sum(parts[i].amount) === result.total`. Useful as an assertion in host code. */
82
82
  declare function isBalanced(result: SplitResult): boolean;
83
+ /** A line a document-level discount may be spread over. */
84
+ interface DiscountSubject {
85
+ readonly id: string;
86
+ /** The line's value before the discount, integer minor units (non-negative). */
87
+ readonly weight: number;
88
+ /** `false` for a line the discount was never computed on (a delivery charge, an auto-added fee). Absent ⇒ eligible. */
89
+ readonly discountable?: boolean;
90
+ }
91
+ /**
92
+ * THE discount allocation: spread a document-level discount over its eligible lines, by value,
93
+ * exact largest remainder. Every document that splits one discount — the order, its invoice,
94
+ * its credit notes, a return's value — calls this, so they agree to the minor unit.
95
+ *
96
+ * Returns a FULL map in input order: every subject id, `0` for an ineligible or zero-weight one.
97
+ * `0` discount allocates zeros; a discount equal to the eligible base allocates all of it; a
98
+ * discount above it throws `DISCOUNT_EXCEEDS_BASE` — the figures disagree.
99
+ */
100
+ declare function allocateDiscount(discount: number, subjects: readonly DiscountSubject[]): ReadonlyMap<string, number>;
83
101
  //#endregion
84
- export { SplitAllocationError, SplitAllocationErrorCode, SplitMethod, SplitPart, SplitResult, SplitSubject, allocate, isBalanced };
102
+ export { DiscountSubject, SplitAllocationError, SplitAllocationErrorCode, SplitMethod, SplitPart, SplitResult, SplitSubject, allocate, allocateDiscount, isBalanced };
@@ -88,6 +88,7 @@ function resolveWeights(subjects, method) {
88
88
  function distribute(total, weights) {
89
89
  const weightSum = weights.reduce((a, b) => a + b, 0);
90
90
  if (weightSum === 0) return weights.map(() => 0);
91
+ if (weights.every(Number.isSafeInteger) && Number.isSafeInteger(weightSum)) return distributeExact(total, weights, weightSum);
91
92
  const raw = weights.map((w) => total * w / weightSum);
92
93
  const floored = raw.map((r) => Math.floor(r));
93
94
  const residue = total - floored.reduce((a, b) => a + b, 0);
@@ -107,9 +108,64 @@ function distribute(total, weights) {
107
108
  }
108
109
  return result;
109
110
  }
111
+ /**
112
+ * Largest remainder in exact integer arithmetic, for integer weights (minor-unit amounts). Shares
113
+ * and remainders are compared as BigInt quotients of one denominator, so neither the floor nor the
114
+ * remainder ranking can drift once `total × weight` passes 2^53.
115
+ */
116
+ function distributeExact(total, weights, weightSum) {
117
+ const t = BigInt(total);
118
+ const d = BigInt(weightSum);
119
+ const shares = weights.map((w) => {
120
+ const n = t * BigInt(w);
121
+ return {
122
+ floor: n / d,
123
+ rem: n % d
124
+ };
125
+ });
126
+ const result = shares.map((s) => Number(s.floor));
127
+ const residue = total - result.reduce((a, b) => a + b, 0);
128
+ const order = shares.map((s, i) => ({
129
+ i,
130
+ rem: s.rem
131
+ })).sort((a, b) => a.rem === b.rem ? a.i - b.i : a.rem > b.rem ? -1 : 1);
132
+ for (let k = 0; k < residue; k++) {
133
+ const entry = order[k];
134
+ if (entry === void 0) throw new Error("split-allocation invariant: residue exceeds the subject count");
135
+ result[entry.i] = (result[entry.i] ?? 0) + 1;
136
+ }
137
+ return result;
138
+ }
110
139
  /** True if `sum(parts[i].amount) === result.total`. Useful as an assertion in host code. */
111
140
  function isBalanced(result) {
112
141
  return result.parts.reduce((acc, p) => acc + p.amount, 0) === result.total;
113
142
  }
143
+ /**
144
+ * THE discount allocation: spread a document-level discount over its eligible lines, by value,
145
+ * exact largest remainder. Every document that splits one discount — the order, its invoice,
146
+ * its credit notes, a return's value — calls this, so they agree to the minor unit.
147
+ *
148
+ * Returns a FULL map in input order: every subject id, `0` for an ineligible or zero-weight one.
149
+ * `0` discount allocates zeros; a discount equal to the eligible base allocates all of it; a
150
+ * discount above it throws `DISCOUNT_EXCEEDS_BASE` — the figures disagree.
151
+ */
152
+ function allocateDiscount(discount, subjects) {
153
+ if (!Number.isInteger(discount) || discount < 0) throw new SplitAllocationError("INVALID_TOTAL", `discount must be a non-negative integer, got ${discount}`);
154
+ const out = /* @__PURE__ */ new Map();
155
+ for (const s of subjects) {
156
+ if (out.has(s.id)) throw new SplitAllocationError("DUPLICATE_SUBJECT_ID", `duplicate subject id: ${s.id}`);
157
+ if (!Number.isFinite(s.weight) || s.weight < 0) throw new SplitAllocationError("INVALID_WEIGHT", `subject ${s.id}: weight must be a non-negative number`);
158
+ out.set(s.id, 0);
159
+ }
160
+ const eligible = subjects.filter((s) => s.discountable !== false && s.weight > 0);
161
+ const base = eligible.reduce((sum, s) => sum + s.weight, 0);
162
+ if (discount > base) throw new SplitAllocationError("DISCOUNT_EXCEEDS_BASE", `discount ${discount} exceeds the discountable base ${base}`);
163
+ if (discount === 0) return out;
164
+ for (const part of allocate(discount, eligible.map((s) => ({
165
+ id: s.id,
166
+ weight: s.weight
167
+ })), "by-value").parts) out.set(part.id, part.amount);
168
+ return out;
169
+ }
114
170
  //#endregion
115
- export { SplitAllocationError, allocate, isBalanced };
171
+ export { SplitAllocationError, allocate, allocateDiscount, isBalanced };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/primitives",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Framework-agnostic TypeScript primitives shared across Classytic packages — Money, events, pagination, tenant config, and other building blocks.",
5
5
  "author": "Classytic",
6
6
  "license": "MIT",
@@ -96,6 +96,11 @@
96
96
  "types": "./dist/memory-outbox.d.mts",
97
97
  "default": "./dist/memory-outbox.mjs"
98
98
  },
99
+ "./durable-events": {
100
+ "@classytic/source": "./src/events/durable.ts",
101
+ "types": "./dist/durable-events.d.mts",
102
+ "default": "./dist/durable-events.mjs"
103
+ },
99
104
  "./event-infra": {
100
105
  "@classytic/source": "./src/events/event-infra.ts",
101
106
  "types": "./dist/event-infra.d.mts",