@classytic/primitives 0.18.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.
- package/CHANGELOG.md +906 -715
- package/README.md +160 -159
- package/dist/approval.d.mts +49 -10
- package/dist/approval.mjs +44 -10
- package/dist/bank-transaction.d.mts +1 -1
- package/dist/cadence-BFQVC1Qp.d.mts +107 -0
- package/dist/cadence.d.mts +1 -92
- package/dist/cadence.mjs +133 -4
- package/dist/calendar-B6fm-FbR.d.mts +46 -0
- package/dist/calendar.d.mts +1 -45
- package/dist/canonical.mjs +17 -0
- package/dist/context.d.mts +28 -1
- package/dist/currency.d.mts +2 -2
- package/dist/currency.mjs +134 -19
- package/dist/event-infra.d.mts +1 -34
- package/dist/event-infra.mjs +16 -46
- package/dist/idempotency.d.mts +253 -0
- package/dist/idempotency.mjs +226 -0
- package/dist/memory-outbox.d.mts +64 -0
- package/dist/memory-outbox.mjs +172 -0
- package/dist/monetization.d.mts +195 -0
- package/dist/monetization.mjs +79 -0
- package/dist/money-DPo9fLBi.d.mts +347 -0
- package/dist/money.d.mts +1 -140
- package/dist/money.mjs +6 -15
- package/dist/outbox.d.mts +49 -1
- package/dist/outbox.mjs +64 -1
- package/dist/payment-events.d.mts +132 -4
- package/dist/payment-events.mjs +22 -2
- package/dist/payment-gateway.d.mts +210 -7
- package/dist/payment-gateway.mjs +18 -1
- package/dist/period.d.mts +138 -7
- package/dist/period.mjs +316 -5
- package/dist/person-parse.d.mts +82 -0
- package/dist/person-parse.mjs +109 -0
- package/dist/proration.d.mts +76 -0
- package/dist/proration.mjs +132 -0
- package/dist/sla-policy.d.mts +1 -1
- package/dist/state-machine.mjs +13 -0
- package/dist/suspension.d.mts +1 -1
- package/dist/timezone-tlG7ygwx.d.mts +114 -0
- package/dist/timezone.d.mts +2 -100
- package/dist/timezone.mjs +38 -3
- package/dist/unit-cost-rate.d.mts +1 -2
- package/package.json +244 -224
- package/dist/currency-BCJnJBlb.d.mts +0 -119
package/dist/event-infra.d.mts
CHANGED
|
@@ -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,
|
|
89
|
+
export { ErrorLogger, InProcessEventBus, InProcessEventBusOptions, createInProcessBus };
|
package/dist/event-infra.mjs
CHANGED
|
@@ -78,52 +78,22 @@ function createInProcessBus(options) {
|
|
|
78
78
|
return new InProcessEventBus(options);
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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,
|
|
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 };
|