@classytic/primitives 0.19.0 → 0.20.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +906 -751
  2. package/README.md +160 -159
  3. package/dist/approval.d.mts +49 -10
  4. package/dist/approval.mjs +44 -10
  5. package/dist/bank-transaction.d.mts +1 -1
  6. package/dist/cadence-BFQVC1Qp.d.mts +107 -0
  7. package/dist/cadence.d.mts +1 -92
  8. package/dist/cadence.mjs +133 -4
  9. package/dist/calendar-B6fm-FbR.d.mts +46 -0
  10. package/dist/calendar.d.mts +1 -45
  11. package/dist/canonical.mjs +17 -0
  12. package/dist/context.d.mts +28 -1
  13. package/dist/currency.d.mts +2 -2
  14. package/dist/currency.mjs +134 -19
  15. package/dist/event-infra.d.mts +1 -34
  16. package/dist/event-infra.mjs +16 -46
  17. package/dist/idempotency.d.mts +253 -0
  18. package/dist/idempotency.mjs +226 -0
  19. package/dist/memory-outbox.d.mts +64 -0
  20. package/dist/memory-outbox.mjs +172 -0
  21. package/dist/monetization.d.mts +1 -1
  22. package/dist/money-DPo9fLBi.d.mts +347 -0
  23. package/dist/money.d.mts +1 -140
  24. package/dist/money.mjs +6 -15
  25. package/dist/outbox.d.mts +49 -1
  26. package/dist/outbox.mjs +64 -1
  27. package/dist/payment-events.d.mts +132 -4
  28. package/dist/payment-events.mjs +22 -2
  29. package/dist/payment-gateway.d.mts +210 -7
  30. package/dist/payment-gateway.mjs +18 -1
  31. package/dist/period.d.mts +138 -7
  32. package/dist/period.mjs +316 -5
  33. package/dist/person-parse.d.mts +82 -0
  34. package/dist/person-parse.mjs +109 -0
  35. package/dist/proration.d.mts +2 -2
  36. package/dist/proration.mjs +18 -6
  37. package/dist/sla-policy.d.mts +1 -1
  38. package/dist/state-machine.mjs +13 -0
  39. package/dist/suspension.d.mts +1 -1
  40. package/dist/timezone-tlG7ygwx.d.mts +114 -0
  41. package/dist/timezone.d.mts +2 -100
  42. package/dist/timezone.mjs +38 -3
  43. package/dist/unit-cost-rate.d.mts +1 -2
  44. package/package.json +244 -232
  45. package/dist/currency-BCJnJBlb.d.mts +0 -119
@@ -1,5 +1,4 @@
1
1
  import { DomainEvent, EventHandler, EventTransport, PublishManyResult } from "./events.mjs";
2
- import { OutboxAcknowledgeOptions, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
3
2
 
4
3
  //#region src/events/event-infra.d.ts
5
4
  /** Minimal error-only logger — assignable from `console`, pino, fastify.log. */
@@ -86,37 +85,5 @@ declare class InProcessEventBus implements EventTransport {
86
85
  }
87
86
  /** Factory form of {@link InProcessEventBus} for hosts that prefer functions. */
88
87
  declare function createInProcessBus(options?: InProcessEventBusOptions): InProcessEventBus;
89
- /**
90
- * In-memory {@link OutboxStore} for tests and local dev. Not for production —
91
- * hosts wire arc's `MongoOutboxStore`, a mongokit repo via
92
- * `repositoryAsOutboxStore`, or any durable implementation.
93
- *
94
- * Implements the required trio (`save` / `getPending` / `acknowledge`) plus
95
- * optional `purge`. Lease-based methods (`claimPending` / `fail` /
96
- * `getDeadLettered`) are intentionally omitted — single-process tests don't
97
- * need them, and kernels only call `save()` themselves. `all()` / `clear()`
98
- * are test-inspection helpers (not part of the contract).
99
- */
100
- declare class MemoryOutboxStore implements OutboxStore {
101
- /**
102
- * Deliberately NOT transactional — and saying so is the point.
103
- *
104
- * This store pushes onto an in-process array, which no database transaction
105
- * can roll back. If a caller's Mongo write aborts after `save`, the event
106
- * SURVIVES here and describes something that never happened. Declaring
107
- * `false` lets a caller that needs real atomicity (`@classytic/access`) refuse
108
- * at boot instead of silently inheriting a ghost-event path.
109
- */
110
- readonly transactionalSave = false;
111
- private events;
112
- save(event: DomainEvent, _options?: OutboxWriteOptions): Promise<void>;
113
- getPending(limit: number): Promise<DomainEvent[]>;
114
- acknowledge(eventId: string, _options?: OutboxAcknowledgeOptions): Promise<void>;
115
- purge(olderThanMs: number): Promise<number>;
116
- /** Every saved event (acknowledged or not) — test inspection helper. */
117
- all(): ReadonlyArray<DomainEvent>;
118
- /** Reset the store — test helper. */
119
- clear(): void;
120
- }
121
88
  //#endregion
122
- export { ErrorLogger, InProcessEventBus, InProcessEventBusOptions, MemoryOutboxStore, createInProcessBus };
89
+ export { ErrorLogger, InProcessEventBus, InProcessEventBusOptions, createInProcessBus };
@@ -78,52 +78,22 @@ function createInProcessBus(options) {
78
78
  return new InProcessEventBus(options);
79
79
  }
80
80
  /**
81
- * In-memory {@link OutboxStore} for tests and local dev. Not for production —
82
- * hosts wire arc's `MongoOutboxStore`, a mongokit repo via
83
- * `repositoryAsOutboxStore`, or any durable implementation.
81
+ * `MemoryOutboxStore` does NOT live here. It is exported from exactly one
82
+ * place: `@classytic/primitives/memory-outbox`.
84
83
  *
85
- * Implements the required trio (`save` / `getPending` / `acknowledge`) plus
86
- * optional `purge`. Lease-based methods (`claimPending` / `fail` /
87
- * `getDeadLettered`) are intentionally omitted — single-process tests don't
88
- * need them, and kernels only call `save()` themselves. `all()` / `clear()`
89
- * are test-inspection helpers (not part of the contract).
84
+ * This module used to also export it — first as a second, MINIMAL
85
+ * implementation (save / getPending / acknowledge / purge, no leases, no
86
+ * retry, no dead-lettering, no dedupe, no validation), then briefly as a
87
+ * back-compat re-export. Both are gone.
88
+ *
89
+ * The re-export was tempting: seven kernels imported it from here, and one
90
+ * line would have spared seven rebuilds. It was still the wrong call. Two live
91
+ * import paths for one class means new code picks either, the migration stays
92
+ * permanently half-done, and the next person has to learn which path is
93
+ * "real". The cost of the move is seven rebuilds; paying it once is cheaper
94
+ * than carrying a shim forever.
95
+ *
96
+ * This subpath is the in-process BUS. The outbox is its own subpath.
90
97
  */
91
- var MemoryOutboxStore = class {
92
- /**
93
- * Deliberately NOT transactional — and saying so is the point.
94
- *
95
- * This store pushes onto an in-process array, which no database transaction
96
- * can roll back. If a caller's Mongo write aborts after `save`, the event
97
- * SURVIVES here and describes something that never happened. Declaring
98
- * `false` lets a caller that needs real atomicity (`@classytic/access`) refuse
99
- * at boot instead of silently inheriting a ghost-event path.
100
- */
101
- transactionalSave = false;
102
- events = [];
103
- async save(event, _options) {
104
- this.events.push({ event });
105
- }
106
- async getPending(limit) {
107
- return this.events.filter((e) => !e.acknowledgedAt).slice(0, limit).map((e) => e.event);
108
- }
109
- async acknowledge(eventId, _options) {
110
- const entry = this.events.find((e) => e.event.meta.id === eventId);
111
- if (entry) entry.acknowledgedAt = /* @__PURE__ */ new Date();
112
- }
113
- async purge(olderThanMs) {
114
- const cutoff = Date.now() - olderThanMs;
115
- const before = this.events.length;
116
- this.events = this.events.filter((e) => !e.acknowledgedAt || e.acknowledgedAt.getTime() >= cutoff);
117
- return before - this.events.length;
118
- }
119
- /** Every saved event (acknowledged or not) — test inspection helper. */
120
- all() {
121
- return this.events.map((e) => e.event);
122
- }
123
- /** Reset the store — test helper. */
124
- clear() {
125
- this.events = [];
126
- }
127
- };
128
98
  //#endregion
129
- export { InProcessEventBus, MemoryOutboxStore, createInProcessBus };
99
+ export { InProcessEventBus, createInProcessBus };
@@ -0,0 +1,253 @@
1
+ //#region src/workflow/idempotency.d.ts
2
+ /**
3
+ * Idempotency claim + lease — the CONTRACT, not the store.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Every module that accepts a command needs to answer the same three-part
8
+ * question when a key arrives: *have I seen this, am I still working on it, or
9
+ * is this the first time?* Three kernels already answer it, incompatibly:
10
+ *
11
+ * | package | states | lease | replay |
12
+ * |---|---|---|---|
13
+ * | `@classytic/cart` | `in_flight` / `succeeded` / `failed` | yes — token + expiry | inline or pointer |
14
+ * | `@classytic/catalog` (offers) | `pending` / `completed` | **no** | serialized string |
15
+ * | `@classytic/contract` (amendments) | implicit — `claimedAt` + `attempts` | partial | none |
16
+ *
17
+ * Only the first models the crash window. The second cannot distinguish "still
18
+ * running" from "crashed mid-write", so a retry either blocks forever on a dead
19
+ * `pending` row or re-applies the mutation — and neither outcome errors. The
20
+ * third has no state at all.
21
+ *
22
+ * Left alone, every spine module that accepts a command invents a fourth shape.
23
+ * This module is the shared vocabulary: types plus PURE state functions. It
24
+ * owns no collection, no index, no driver — the adapter that persists a claim
25
+ * is the caller's.
26
+ *
27
+ * ## The three-valued outcome is the point
28
+ *
29
+ * {@link ClaimOutcome} is `claimed | replayed | in_flight`, and **`in_flight` is
30
+ * not a failure.** A concurrent attempt holding a live lease means the answer is
31
+ * *not yet known*, exactly like a provider timeout: the work may be about to
32
+ * succeed. Mapping it onto an error licenses the caller to retry the mutation,
33
+ * and if the first attempt lands that is a double-apply — the same asymmetry
34
+ * that makes `unknown` a required payment outcome. The correct handling is to
35
+ * wait out {@link InFlightDecision.retryAfterMs} and ask again, never to
36
+ * re-execute.
37
+ *
38
+ * ## Two identifiers that must not be confused
39
+ *
40
+ * - **`key`** — caller-supplied, STABLE across retries of one logical command.
41
+ * Derive it deterministically; a random fallback defeats deduplication while
42
+ * looking correctly wired.
43
+ * - **`leaseToken`** — minted fresh per ATTEMPT, and deliberately random. It
44
+ * answers "am I still the attempt that owns this claim?", so two attempts
45
+ * must never mint the same one. Deriving it would make a takeover
46
+ * indistinguishable from the attempt it replaced.
47
+ */
48
+ /** Default crash-window lease, matching `@classytic/cart`'s proven 30s. */
49
+ declare const DEFAULT_LEASE_MS = 30000;
50
+ /**
51
+ * Persisted state of a claim.
52
+ *
53
+ * `failed` is TERMINAL and replayable: a command that failed deterministically
54
+ * must fail the same way on retry, or the caller learns a different answer to
55
+ * the same question. It is not a licence to re-execute.
56
+ */
57
+ type ClaimState = 'in_flight' | 'succeeded' | 'failed';
58
+ /**
59
+ * What a claim attempt is allowed to do next.
60
+ *
61
+ * - `claimed` — this attempt owns the claim; EXECUTE the command.
62
+ * - `replayed` — a terminal record exists; RETURN its stored result, execute nothing.
63
+ * - `in_flight` — another live attempt owns it; WAIT and re-ask. **Not a failure.**
64
+ */
65
+ type ClaimOutcome = 'claimed' | 'replayed' | 'in_flight';
66
+ /** The stored outcome of a completed command — replayed verbatim to retries. */
67
+ type ClaimResult<TValue = unknown> = {
68
+ readonly status: 'succeeded';
69
+ readonly value: TValue;
70
+ } | {
71
+ readonly status: 'failed';
72
+ readonly error: ClaimError;
73
+ };
74
+ /**
75
+ * A replayable failure. `code` is a CLOSED host vocabulary, never a raw vendor
76
+ * string — this value is persisted and returned to callers.
77
+ */
78
+ interface ClaimError {
79
+ readonly code: string;
80
+ readonly message: string;
81
+ }
82
+ /**
83
+ * The composite identity of a claim.
84
+ *
85
+ * `key` alone is not the identity. Two different operations must never share a
86
+ * key, and a key issued for one aggregate/actor must not satisfy a command
87
+ * against another — so the operation and any scoping segments are part of what
88
+ * the unique index covers. `@classytic/cart` learned this as
89
+ * `(organizationId, cartRef, operation, actorRef, idempotencyKey)`.
90
+ */
91
+ interface IdempotencyIdentity {
92
+ /** The command this key was issued for — `'checkout.finalize'`, `'purchase.pay'`. */
93
+ readonly operation: string;
94
+ /** Caller-supplied key, stable across retries of the SAME logical command. */
95
+ readonly key: string;
96
+ /** Scoping segments — organizationId, aggregate ref, actor ref. */
97
+ readonly scope?: Readonly<Record<string, string>>;
98
+ }
99
+ /**
100
+ * A claim record. The adapter persists exactly these fields (plus whatever it
101
+ * needs for TTL), with a unique index over {@link IdempotencyIdentity}.
102
+ */
103
+ interface IdempotencyClaim<TValue = unknown> {
104
+ readonly identity: IdempotencyIdentity;
105
+ /**
106
+ * Digest of the request body this key was first used with. A retry that
107
+ * presents the same key with a DIFFERENT body is not a retry — see
108
+ * {@link decideClaim}.
109
+ */
110
+ readonly requestFingerprint: string;
111
+ readonly state: ClaimState;
112
+ /** Which ATTEMPT currently owns the claim. Rotates on takeover. */
113
+ readonly leaseToken: string;
114
+ /** After this instant the lease is dead and another attempt may take over. */
115
+ readonly leaseExpiresAt: Date;
116
+ readonly createdAt: Date;
117
+ /** Set when `state` becomes terminal. */
118
+ readonly completedAt?: Date;
119
+ /** Present iff `state` is terminal. Absent on `in_flight`. */
120
+ readonly result?: ClaimResult<TValue>;
121
+ /** Monotonic attempt counter — each successful takeover increments it. */
122
+ readonly attempts: number;
123
+ }
124
+ type IdempotencyErrorCode = /** Same key, different request body. Never a retry — two commands sharing one key. */'FINGERPRINT_MISMATCH' /** A terminal claim carrying no stored result. Unreplayable; must not answer "ok". */ | 'MISSING_REPLAY' /** The attempt no longer owns the lease — another attempt took over. */ | 'LEASE_LOST' /** Completing / renewing a claim that already reached a terminal state. */ | 'ALREADY_TERMINAL' /** Malformed input (empty key, non-positive lease, invalid instant). */ | 'INVALID_CLAIM';
125
+ declare class IdempotencyError extends Error {
126
+ readonly name = "IdempotencyError";
127
+ readonly code: IdempotencyErrorCode;
128
+ constructor(code: IdempotencyErrorCode, message: string);
129
+ }
130
+ /**
131
+ * The deterministic string form of an identity — safe as a single unique-index
132
+ * key when a compound index is not available.
133
+ *
134
+ * Built with canonical JSON rather than `join(':')`: a delimiter-joined
135
+ * composite lets `{ ref: 'a:b', actor: 'c' }` and `{ ref: 'a', actor: 'b:c' }`
136
+ * produce the SAME key, which silently makes one caller's claim satisfy
137
+ * another's command.
138
+ */
139
+ declare function identityKey(identity: IdempotencyIdentity): string;
140
+ /** True when two identities address the same claim. */
141
+ declare function sameIdentity(a: IdempotencyIdentity, b: IdempotencyIdentity): boolean;
142
+ /**
143
+ * Fingerprint a request body — `sha256(canonicalJson(body))`.
144
+ *
145
+ * Deterministic and key-order independent, so a client that reserializes its
146
+ * body does not read as a different request. Use the WHOLE body that decides
147
+ * the mutation; fingerprinting a subset means two genuinely different commands
148
+ * share a fingerprint and one silently replays the other's result.
149
+ */
150
+ declare function fingerprintRequest(body: unknown): string;
151
+ /**
152
+ * Mint a lease token for ONE attempt.
153
+ *
154
+ * Random on purpose — see the module docblock. This is the one identifier in
155
+ * the idempotency contract that must NOT be derived.
156
+ */
157
+ declare function newLeaseToken(): string;
158
+ /** Has the crash-window lease lapsed at `now`? Expiry is exclusive of `now`. */
159
+ declare function isLeaseExpired(claim: IdempotencyClaim<unknown>, now: Date): boolean;
160
+ /** Does `leaseToken` still own this claim at `now`? */
161
+ declare function holdsLease(claim: IdempotencyClaim<unknown>, leaseToken: string, now: Date): boolean;
162
+ interface ClaimRequest {
163
+ readonly identity: IdempotencyIdentity;
164
+ readonly requestFingerprint: string;
165
+ /** Injected clock — no ambient `new Date()`, so the crash window is testable. */
166
+ readonly now: Date;
167
+ /** Injected token for THIS attempt — see {@link newLeaseToken}. */
168
+ readonly leaseToken: string;
169
+ /** Crash-window length. Default {@link DEFAULT_LEASE_MS}. */
170
+ readonly leaseMs?: number;
171
+ }
172
+ /** This attempt owns the claim and must EXECUTE the command. */
173
+ interface ClaimedDecision<TValue> {
174
+ readonly outcome: 'claimed';
175
+ readonly claim: IdempotencyClaim<TValue>;
176
+ /**
177
+ * Present when this claim took over a lapsed lease — the dead attempt's
178
+ * token. Persist the takeover as a CONDITIONAL update on that token so two
179
+ * simultaneous takeovers cannot both win.
180
+ */
181
+ readonly tookOverFrom?: string;
182
+ }
183
+ /** A terminal record exists: RETURN its result, execute nothing. */
184
+ interface ReplayedDecision<TValue> {
185
+ readonly outcome: 'replayed';
186
+ readonly claim: IdempotencyClaim<TValue>;
187
+ readonly result: ClaimResult<TValue>;
188
+ }
189
+ /**
190
+ * Another attempt holds a live lease. **Not a failure** — the answer is not yet
191
+ * known. Wait `retryAfterMs` and re-ask; never re-execute.
192
+ */
193
+ interface InFlightDecision<TValue> {
194
+ readonly outcome: 'in_flight';
195
+ readonly claim: IdempotencyClaim<TValue>;
196
+ /** How long until the incumbent's lease lapses. Always > 0. */
197
+ readonly retryAfterMs: number;
198
+ }
199
+ type ClaimDecision<TValue = unknown> = ClaimedDecision<TValue> | ReplayedDecision<TValue> | InFlightDecision<TValue>;
200
+ /**
201
+ * The whole state machine, as one pure function.
202
+ *
203
+ * | stored state | lease | decision |
204
+ * |---|---|---|
205
+ * | (none) | — | `claimed` — fresh claim |
206
+ * | `in_flight` | live | `in_flight` — wait, do NOT execute |
207
+ * | `in_flight` | lapsed | `claimed` — takeover, `tookOverFrom` set |
208
+ * | `succeeded` / `failed` | — | `replayed` — return the stored result |
209
+ *
210
+ * Two conditions throw rather than returning a decision, because both mean the
211
+ * question itself was wrong:
212
+ *
213
+ * - **`FINGERPRINT_MISMATCH`** — the same key with a different body. That is
214
+ * two commands, not one retried command; answering either way is wrong.
215
+ * Surface it as a 4xx (Stripe returns 400 here), never as a fresh claim.
216
+ * - **`MISSING_REPLAY`** — a terminal record with no stored result. There is
217
+ * nothing to replay, and inventing an empty success is precisely the silent
218
+ * permissive default this codebase keeps getting bitten by.
219
+ */
220
+ declare function decideClaim<TValue = unknown>(existing: IdempotencyClaim<TValue> | null | undefined, request: ClaimRequest): ClaimDecision<TValue>;
221
+ /**
222
+ * Extend a live lease — for a command that legitimately outruns the crash
223
+ * window. Throws `LEASE_LOST` when the caller no longer owns the claim, so a
224
+ * superseded attempt cannot silently keep working and then write its result
225
+ * over the winner's.
226
+ */
227
+ declare function renewLease<TValue>(claim: IdempotencyClaim<TValue>, input: {
228
+ readonly leaseToken: string;
229
+ readonly now: Date;
230
+ readonly leaseMs?: number;
231
+ }): IdempotencyClaim<TValue>;
232
+ /**
233
+ * Move a claim to a terminal state with the result retries will replay.
234
+ *
235
+ * The lease check is the important part: an attempt whose lease lapsed and was
236
+ * taken over must NOT write its result. Without it, the slow attempt's answer
237
+ * overwrites the takeover's, and every subsequent retry replays an outcome that
238
+ * does not match what was actually applied.
239
+ */
240
+ declare function completeClaim<TValue>(claim: IdempotencyClaim<TValue>, input: {
241
+ readonly leaseToken: string;
242
+ readonly now: Date;
243
+ readonly result: ClaimResult<TValue>;
244
+ }): IdempotencyClaim<TValue>;
245
+ /**
246
+ * Narrowing helper for the one outcome callers keep getting wrong.
247
+ *
248
+ * `in_flight` is neither success nor failure. Route it to a 409 + `Retry-After`
249
+ * (or an internal wait), never to the command's error path.
250
+ */
251
+ declare function isInFlight<TValue>(decision: ClaimDecision<TValue>): decision is InFlightDecision<TValue>;
252
+ //#endregion
253
+ export { ClaimDecision, ClaimError, ClaimOutcome, ClaimRequest, ClaimResult, ClaimState, ClaimedDecision, DEFAULT_LEASE_MS, IdempotencyClaim, IdempotencyError, IdempotencyErrorCode, IdempotencyIdentity, InFlightDecision, ReplayedDecision, completeClaim, decideClaim, fingerprintRequest, holdsLease, identityKey, isInFlight, isLeaseExpired, newLeaseToken, renewLease, sameIdentity };
@@ -0,0 +1,226 @@
1
+ import { canonicalDigest, canonicalJson } from "./canonical.mjs";
2
+ //#region src/workflow/idempotency.ts
3
+ /**
4
+ * Idempotency claim + lease — the CONTRACT, not the store.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * Every module that accepts a command needs to answer the same three-part
9
+ * question when a key arrives: *have I seen this, am I still working on it, or
10
+ * is this the first time?* Three kernels already answer it, incompatibly:
11
+ *
12
+ * | package | states | lease | replay |
13
+ * |---|---|---|---|
14
+ * | `@classytic/cart` | `in_flight` / `succeeded` / `failed` | yes — token + expiry | inline or pointer |
15
+ * | `@classytic/catalog` (offers) | `pending` / `completed` | **no** | serialized string |
16
+ * | `@classytic/contract` (amendments) | implicit — `claimedAt` + `attempts` | partial | none |
17
+ *
18
+ * Only the first models the crash window. The second cannot distinguish "still
19
+ * running" from "crashed mid-write", so a retry either blocks forever on a dead
20
+ * `pending` row or re-applies the mutation — and neither outcome errors. The
21
+ * third has no state at all.
22
+ *
23
+ * Left alone, every spine module that accepts a command invents a fourth shape.
24
+ * This module is the shared vocabulary: types plus PURE state functions. It
25
+ * owns no collection, no index, no driver — the adapter that persists a claim
26
+ * is the caller's.
27
+ *
28
+ * ## The three-valued outcome is the point
29
+ *
30
+ * {@link ClaimOutcome} is `claimed | replayed | in_flight`, and **`in_flight` is
31
+ * not a failure.** A concurrent attempt holding a live lease means the answer is
32
+ * *not yet known*, exactly like a provider timeout: the work may be about to
33
+ * succeed. Mapping it onto an error licenses the caller to retry the mutation,
34
+ * and if the first attempt lands that is a double-apply — the same asymmetry
35
+ * that makes `unknown` a required payment outcome. The correct handling is to
36
+ * wait out {@link InFlightDecision.retryAfterMs} and ask again, never to
37
+ * re-execute.
38
+ *
39
+ * ## Two identifiers that must not be confused
40
+ *
41
+ * - **`key`** — caller-supplied, STABLE across retries of one logical command.
42
+ * Derive it deterministically; a random fallback defeats deduplication while
43
+ * looking correctly wired.
44
+ * - **`leaseToken`** — minted fresh per ATTEMPT, and deliberately random. It
45
+ * answers "am I still the attempt that owns this claim?", so two attempts
46
+ * must never mint the same one. Deriving it would make a takeover
47
+ * indistinguishable from the attempt it replaced.
48
+ */
49
+ /** Default crash-window lease, matching `@classytic/cart`'s proven 30s. */
50
+ const DEFAULT_LEASE_MS = 3e4;
51
+ var IdempotencyError = class extends Error {
52
+ name = "IdempotencyError";
53
+ code;
54
+ constructor(code, message) {
55
+ super(message);
56
+ this.code = code;
57
+ }
58
+ };
59
+ /**
60
+ * The deterministic string form of an identity — safe as a single unique-index
61
+ * key when a compound index is not available.
62
+ *
63
+ * Built with canonical JSON rather than `join(':')`: a delimiter-joined
64
+ * composite lets `{ ref: 'a:b', actor: 'c' }` and `{ ref: 'a', actor: 'b:c' }`
65
+ * produce the SAME key, which silently makes one caller's claim satisfy
66
+ * another's command.
67
+ */
68
+ function identityKey(identity) {
69
+ assertNonEmpty(identity.operation, "identity.operation");
70
+ assertNonEmpty(identity.key, "identity.key");
71
+ return canonicalJson({
72
+ operation: identity.operation,
73
+ key: identity.key,
74
+ scope: identity.scope ?? {}
75
+ });
76
+ }
77
+ /** True when two identities address the same claim. */
78
+ function sameIdentity(a, b) {
79
+ return identityKey(a) === identityKey(b);
80
+ }
81
+ /**
82
+ * Fingerprint a request body — `sha256(canonicalJson(body))`.
83
+ *
84
+ * Deterministic and key-order independent, so a client that reserializes its
85
+ * body does not read as a different request. Use the WHOLE body that decides
86
+ * the mutation; fingerprinting a subset means two genuinely different commands
87
+ * share a fingerprint and one silently replays the other's result.
88
+ */
89
+ function fingerprintRequest(body) {
90
+ return canonicalDigest(body);
91
+ }
92
+ /**
93
+ * Mint a lease token for ONE attempt.
94
+ *
95
+ * Random on purpose — see the module docblock. This is the one identifier in
96
+ * the idempotency contract that must NOT be derived.
97
+ */
98
+ function newLeaseToken() {
99
+ const c = globalThis.crypto;
100
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
101
+ return `lease_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
102
+ }
103
+ /** Has the crash-window lease lapsed at `now`? Expiry is exclusive of `now`. */
104
+ function isLeaseExpired(claim, now) {
105
+ return now.getTime() >= claim.leaseExpiresAt.getTime();
106
+ }
107
+ /** Does `leaseToken` still own this claim at `now`? */
108
+ function holdsLease(claim, leaseToken, now) {
109
+ return claim.leaseToken === leaseToken && !isLeaseExpired(claim, now);
110
+ }
111
+ /**
112
+ * The whole state machine, as one pure function.
113
+ *
114
+ * | stored state | lease | decision |
115
+ * |---|---|---|
116
+ * | (none) | — | `claimed` — fresh claim |
117
+ * | `in_flight` | live | `in_flight` — wait, do NOT execute |
118
+ * | `in_flight` | lapsed | `claimed` — takeover, `tookOverFrom` set |
119
+ * | `succeeded` / `failed` | — | `replayed` — return the stored result |
120
+ *
121
+ * Two conditions throw rather than returning a decision, because both mean the
122
+ * question itself was wrong:
123
+ *
124
+ * - **`FINGERPRINT_MISMATCH`** — the same key with a different body. That is
125
+ * two commands, not one retried command; answering either way is wrong.
126
+ * Surface it as a 4xx (Stripe returns 400 here), never as a fresh claim.
127
+ * - **`MISSING_REPLAY`** — a terminal record with no stored result. There is
128
+ * nothing to replay, and inventing an empty success is precisely the silent
129
+ * permissive default this codebase keeps getting bitten by.
130
+ */
131
+ function decideClaim(existing, request) {
132
+ assertNonEmpty(request.requestFingerprint, "request.requestFingerprint");
133
+ assertNonEmpty(request.leaseToken, "request.leaseToken");
134
+ assertInstant(request.now, "request.now");
135
+ const leaseMs = request.leaseMs ?? 3e4;
136
+ if (!Number.isFinite(leaseMs) || leaseMs <= 0) throw new IdempotencyError("INVALID_CLAIM", `leaseMs must be a positive number of milliseconds, got ${String(request.leaseMs)} — a zero or negative lease is instantly expired, so every concurrent attempt takes over and executes.`);
137
+ if (existing == null) return {
138
+ outcome: "claimed",
139
+ claim: mintClaim(request, leaseMs, 1)
140
+ };
141
+ if (existing.requestFingerprint !== request.requestFingerprint) throw new IdempotencyError("FINGERPRINT_MISMATCH", `Idempotency key '${existing.identity.key}' was first used with a different request body (stored ${existing.requestFingerprint.slice(0, 12)}…, presented ${request.requestFingerprint.slice(0, 12)}…). Two distinct commands are sharing one key — replaying the first would return the wrong result, and claiming afresh would apply the second under a key already spent.`);
142
+ if (existing.state === "succeeded" || existing.state === "failed") {
143
+ if (existing.result === void 0) throw new IdempotencyError("MISSING_REPLAY", `Claim '${existing.identity.key}' is terminal (${existing.state}) but stores no result, so the retry cannot be answered. Reporting success here would fabricate an outcome that was never observed.`);
144
+ return {
145
+ outcome: "replayed",
146
+ claim: existing,
147
+ result: existing.result
148
+ };
149
+ }
150
+ if (!isLeaseExpired(existing, request.now)) return {
151
+ outcome: "in_flight",
152
+ claim: existing,
153
+ retryAfterMs: existing.leaseExpiresAt.getTime() - request.now.getTime()
154
+ };
155
+ return {
156
+ outcome: "claimed",
157
+ claim: {
158
+ ...mintClaim(request, leaseMs, existing.attempts + 1),
159
+ createdAt: existing.createdAt
160
+ },
161
+ tookOverFrom: existing.leaseToken
162
+ };
163
+ }
164
+ function mintClaim(request, leaseMs, attempts) {
165
+ identityKey(request.identity);
166
+ return {
167
+ identity: request.identity,
168
+ requestFingerprint: request.requestFingerprint,
169
+ state: "in_flight",
170
+ leaseToken: request.leaseToken,
171
+ leaseExpiresAt: new Date(request.now.getTime() + leaseMs),
172
+ createdAt: request.now,
173
+ attempts
174
+ };
175
+ }
176
+ /**
177
+ * Extend a live lease — for a command that legitimately outruns the crash
178
+ * window. Throws `LEASE_LOST` when the caller no longer owns the claim, so a
179
+ * superseded attempt cannot silently keep working and then write its result
180
+ * over the winner's.
181
+ */
182
+ function renewLease(claim, input) {
183
+ assertInstant(input.now, "now");
184
+ if (claim.state !== "in_flight") throw new IdempotencyError("ALREADY_TERMINAL", `cannot renew a ${claim.state} claim`);
185
+ if (!holdsLease(claim, input.leaseToken, input.now)) throw new IdempotencyError("LEASE_LOST", `lease token does not own this claim at ${input.now.toISOString()} (expired or taken over) — continuing would let two attempts believe they are the live one.`);
186
+ return {
187
+ ...claim,
188
+ leaseExpiresAt: new Date(input.now.getTime() + (input.leaseMs ?? 3e4))
189
+ };
190
+ }
191
+ /**
192
+ * Move a claim to a terminal state with the result retries will replay.
193
+ *
194
+ * The lease check is the important part: an attempt whose lease lapsed and was
195
+ * taken over must NOT write its result. Without it, the slow attempt's answer
196
+ * overwrites the takeover's, and every subsequent retry replays an outcome that
197
+ * does not match what was actually applied.
198
+ */
199
+ function completeClaim(claim, input) {
200
+ assertInstant(input.now, "now");
201
+ if (claim.state !== "in_flight") throw new IdempotencyError("ALREADY_TERMINAL", `claim is already ${claim.state}; completing it again would replace a result that retries may already have replayed.`);
202
+ if (claim.leaseToken !== input.leaseToken) throw new IdempotencyError("LEASE_LOST", "lease token does not own this claim — another attempt took it over, and writing this result would overwrite theirs.");
203
+ return {
204
+ ...claim,
205
+ state: input.result.status,
206
+ result: input.result,
207
+ completedAt: input.now
208
+ };
209
+ }
210
+ /**
211
+ * Narrowing helper for the one outcome callers keep getting wrong.
212
+ *
213
+ * `in_flight` is neither success nor failure. Route it to a 409 + `Retry-After`
214
+ * (or an internal wait), never to the command's error path.
215
+ */
216
+ function isInFlight(decision) {
217
+ return decision.outcome === "in_flight";
218
+ }
219
+ function assertNonEmpty(value, label) {
220
+ if (typeof value !== "string" || value.length === 0) throw new IdempotencyError("INVALID_CLAIM", `${label} must be a non-empty string, got ${String(value)}`);
221
+ }
222
+ function assertInstant(value, label) {
223
+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new IdempotencyError("INVALID_CLAIM", `${label} must be a valid Date, got ${String(value)}`);
224
+ }
225
+ //#endregion
226
+ export { DEFAULT_LEASE_MS, IdempotencyError, completeClaim, decideClaim, fingerprintRequest, holdsLease, identityKey, isInFlight, isLeaseExpired, newLeaseToken, renewLease, sameIdentity };
@@ -0,0 +1,64 @@
1
+ import { DeadLetteredEvent, DomainEvent } from "./events.mjs";
2
+ import { OutboxAcknowledgeOptions, OutboxClaimOptions, OutboxErrorInfo, OutboxFailOptions, OutboxStatus, OutboxStore, OutboxWriteOptions } from "./outbox.mjs";
3
+
4
+ //#region src/events/memory-outbox.d.ts
5
+ interface MemoryEntry {
6
+ event: DomainEvent;
7
+ status: "pending" | "delivered" | "dead_letter";
8
+ attempts: number;
9
+ visibleAt: number;
10
+ leaseOwner: string | null;
11
+ leaseExpiresAt: number;
12
+ deliveredAt: number | null;
13
+ firstFailedAt: number | null;
14
+ lastFailedAt: number | null;
15
+ lastError: OutboxErrorInfo | null;
16
+ dedupeKey?: string;
17
+ /**
18
+ * When the row was written. Distinct from `visibleAt`, which retries push forward —
19
+ * `oldestPendingAgeMs` must answer "how long has this gone undelivered", and reading
20
+ * it off a field that every retry resets would make a permanently-failing row look
21
+ * permanently fresh.
22
+ */
23
+ createdAt: number;
24
+ }
25
+ declare class MemoryOutboxStore implements OutboxStore {
26
+ private readonly entries;
27
+ private readonly seenDedupeKeys;
28
+ /**
29
+ * Drop every entry (including delivered / failed / dead-lettered rows) and
30
+ * the dedupe-key memory.
31
+ *
32
+ * For tests only — it is the in-memory analogue of truncating the table, and
33
+ * exists so a suite can isolate cases against ONE store instance (the
34
+ * `reset` hook of `runOutboxStoreContract`). Not part of `OutboxStore`; a
35
+ * production store must never expose this.
36
+ */
37
+ clear(): void;
38
+ /**
39
+ * For tests only — every stored event regardless of status, in insertion
40
+ * order. The in-memory analogue of `SELECT * FROM outbox`, used by the
41
+ * `/event-infra` contract test to assert what a `purge` left behind. Not part
42
+ * of `OutboxStore`; a production store must never expose this.
43
+ */
44
+ all(): DomainEvent[];
45
+ save(event: DomainEvent, options?: OutboxWriteOptions): Promise<void>;
46
+ getPending(limit: number): Promise<DomainEvent[]>;
47
+ claimPending(options?: OutboxClaimOptions): Promise<DomainEvent[]>;
48
+ acknowledge(eventId: string, options?: OutboxAcknowledgeOptions): Promise<void>;
49
+ fail(eventId: string, error: OutboxErrorInfo, options?: OutboxFailOptions): Promise<void>;
50
+ getDeadLettered(limit: number): Promise<DeadLetteredEvent[]>;
51
+ purge(olderThanMs: number): Promise<number>;
52
+ /**
53
+ * Re-drive one dead-lettered row. Scoped to `dead_letter`: a row that is merely
54
+ * backing off must keep its attempt count, and the id alone cannot tell the two apart.
55
+ */
56
+ requeue(eventId: string): Promise<boolean>;
57
+ countByStatus(status: OutboxStatus): Promise<number>;
58
+ /** Age of the oldest PENDING row — see `MemoryEntry.createdAt` for why not `visibleAt`. */
59
+ oldestPendingAgeMs(): Promise<number | null>;
60
+ /** Test helper: inspect entry by id */
61
+ _getEntry(eventId: string): Readonly<MemoryEntry> | undefined;
62
+ }
63
+ //#endregion
64
+ export { MemoryOutboxStore };