@classytic/repo-core 0.28.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +1290 -1098
  2. package/dist/cache/engine.d.mts +7 -5
  3. package/dist/cache/engine.mjs +34 -16
  4. package/dist/cache/memo.d.mts +4 -0
  5. package/dist/cache/memo.mjs +17 -4
  6. package/dist/cache/memory-adapter.mjs +6 -4
  7. package/dist/cache/plugin/read-hooks.mjs +1 -1
  8. package/dist/cache/stable-stringify.d.mts +7 -8
  9. package/dist/cache/stable-stringify.mjs +14 -11
  10. package/dist/cache/tag-index.mjs +1 -2
  11. package/dist/cache/types.d.mts +4 -3
  12. package/dist/errors/codes.d.mts +117 -0
  13. package/dist/errors/codes.mjs +114 -0
  14. package/dist/errors/contract.d.mts +2 -1
  15. package/dist/errors/contract.mjs +9 -3
  16. package/dist/errors/index.d.mts +3 -2
  17. package/dist/errors/index.mjs +2 -2
  18. package/dist/errors/types.d.mts +3 -30
  19. package/dist/filter/builders.mjs +1 -1
  20. package/dist/filter/match-record.mjs +9 -3
  21. package/dist/filter/match.mjs +25 -9
  22. package/dist/idempotency/index.d.mts +462 -0
  23. package/dist/idempotency/index.mjs +419 -0
  24. package/dist/pagination/types.d.mts +6 -0
  25. package/dist/query-parser/coerce.d.mts +18 -11
  26. package/dist/query-parser/coerce.mjs +37 -69
  27. package/dist/query-parser/errors.d.mts +30 -0
  28. package/dist/query-parser/errors.mjs +32 -0
  29. package/dist/query-parser/grammar.d.mts +107 -0
  30. package/dist/query-parser/grammar.mjs +462 -0
  31. package/dist/query-parser/index.d.mts +5 -2
  32. package/dist/query-parser/index.mjs +5 -2
  33. package/dist/query-parser/parse-url.d.mts +1 -1
  34. package/dist/query-parser/parse-url.mjs +72 -205
  35. package/dist/query-parser/regex-safety.d.mts +31 -0
  36. package/dist/query-parser/regex-safety.mjs +151 -0
  37. package/dist/query-parser/reserved.mjs +1 -0
  38. package/dist/query-parser/types.d.mts +15 -25
  39. package/dist/repository/types.d.mts +6 -5
  40. package/dist/schema/field-rules.mjs +49 -6
  41. package/dist/schema/standard-schema.mjs +1 -1
  42. package/dist/sync/index.d.mts +90 -55
  43. package/dist/sync/index.mjs +0 -0
  44. package/dist/testing/change-log-conformance.d.mts +21 -0
  45. package/dist/testing/change-log-conformance.mjs +204 -0
  46. package/dist/testing/command-stream-conformance.d.mts +15 -0
  47. package/dist/testing/command-stream-conformance.mjs +163 -0
  48. package/dist/testing/idempotency-conformance.d.mts +15 -0
  49. package/dist/testing/idempotency-conformance.mjs +315 -0
  50. package/dist/testing/index.d.mts +5 -1
  51. package/dist/testing/index.mjs +5 -1
  52. package/dist/testing/query-grammar-conformance.d.mts +60 -0
  53. package/dist/testing/query-grammar-conformance.mjs +380 -0
  54. package/package.json +6 -2
  55. package/dist/errors/types.mjs +0 -29
@@ -166,7 +166,7 @@ function raw(sql, params = []) {
166
166
  });
167
167
  }
168
168
  function escapeLikePattern(value) {
169
- return value.replace(/[%_]/g, (c) => `\\${c}`);
169
+ return value.replace(/[\\%_]/g, (c) => `\\${c}`);
170
170
  }
171
171
  //#endregion
172
172
  export { FALSE, TRUE, and, in_ as anyOf, in_, between, contains, endsWith, eq, exists, gt, gte, iEq, not as invert, not, isNotNull, isNull, like, lt, lte, ne, nin, nin as noneOf, or, raw, regex, startsWith };
@@ -1,4 +1,4 @@
1
- import { FALSE, TRUE, and, anyOf as in_, eq, gt, gte, invert as not, isNotNull, isNull, lt, lte, ne, or, regex } from "./builders.mjs";
1
+ import { FALSE, TRUE, and, anyOf as in_, eq, gt, gte, invert as not, isNotNull, isNull, lt, lte, nin, or, regex } from "./builders.mjs";
2
2
  import { matchFilter } from "./match.mjs";
3
3
  //#region src/filter/match-record.ts
4
4
  /**
@@ -102,8 +102,14 @@ function buildIn(field, members) {
102
102
  if (branches.length === 0) return FALSE;
103
103
  return branches.length === 1 ? branches[0] : or(...branches);
104
104
  }
105
- /** `$nin` is the negation of `$in` — none of the members may match. */
105
+ /**
106
+ * `$nin` is the negation of `$in` — none of the members may match. With a `null` member and only
107
+ * scalars it is exactly the IR's strict `nin` (null and missing excluded), so it maps to that
108
+ * directly and compiles back to one `$nin` instead of a `$nor` of an `$or`.
109
+ */
106
110
  function buildNin(field, members) {
111
+ const scalars = members.filter((m) => m !== null && m !== void 0);
112
+ if (scalars.length < members.length && !scalars.some((m) => m instanceof RegExp)) return scalars.length === 0 ? isNotNull(field) : nin(field, scalars);
107
113
  const inFilter = buildIn(field, members);
108
114
  return inFilter.op === "false" ? TRUE : not(inFilter);
109
115
  }
@@ -118,7 +124,7 @@ function fieldFilter(field, condition) {
118
124
  parts.push(operand === null ? isNull(field) : eq(field, operand));
119
125
  break;
120
126
  case "$ne":
121
- parts.push(operand === null ? isNotNull(field) : ne(field, operand));
127
+ parts.push(operand === null ? isNotNull(field) : not(eq(field, operand)));
122
128
  break;
123
129
  case "$gt":
124
130
  parts.push(gt(field, operand));
@@ -7,14 +7,21 @@ function matchFilter(doc, filter) {
7
7
  case "and": return filter.children.every((child) => matchFilter(doc, child));
8
8
  case "or": return filter.children.some((child) => matchFilter(doc, child));
9
9
  case "not": return !matchFilter(doc, filter.child);
10
- case "eq": return someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
11
- case "ne": return !someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
10
+ case "eq":
11
+ if (filter.value === null) return !fieldPresent(doc, filter.field);
12
+ return someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
13
+ case "ne": return fieldPresent(doc, filter.field) && !someValue(resolve(doc, filter.field), (v) => equals(v, filter.value));
12
14
  case "gt": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) > 0);
13
15
  case "gte": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) >= 0);
14
16
  case "lt": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) < 0);
15
17
  case "lte": return someValue(resolve(doc, filter.field), (v) => compare(v, filter.value) <= 0);
16
- case "in": return someValue(resolve(doc, filter.field), (v) => filter.values.some((candidate) => equals(v, candidate)));
17
- case "nin": return !someValue(resolve(doc, filter.field), (v) => filter.values.some((candidate) => equals(v, candidate)));
18
+ case "in":
19
+ if (filter.values.includes(null) && !fieldPresent(doc, filter.field)) return true;
20
+ return someValue(resolve(doc, filter.field), (v) => filter.values.some((candidate) => equals(v, candidate)));
21
+ case "nin": {
22
+ const vs = resolve(doc, filter.field);
23
+ return fieldPresent(doc, filter.field) && !someValue(vs, (v) => filter.values.some((candidate) => equals(v, candidate)));
24
+ }
18
25
  case "exists": {
19
26
  const present = fieldPresent(doc, filter.field);
20
27
  return filter.exists ? present : !present;
@@ -185,13 +192,22 @@ const MAX_REGEX_INPUT = 65536;
185
192
  function regexTest(re, v) {
186
193
  return typeof v === "string" && v.length <= MAX_REGEX_INPUT && re.test(v);
187
194
  }
188
- /** SQL `LIKE` pattern → JS regex body. Escapes regex metachars; `%` → `.*`, `_` → `.`. */
195
+ /** SQL `LIKE` → regex body: `%` → `.*`, `_` → `.`, and the `\` escape (`\%`, `\_`, `\\`) as every kit compiles it. */
189
196
  function likeToRegex(pattern) {
190
197
  let out = "";
191
- for (const ch of pattern) if (ch === "%") out += ".*";
192
- else if (ch === "_") out += ".";
193
- else if (/[.*+?^${}()|[\]\\]/.test(ch)) out += `\\${ch}`;
194
- else out += ch;
198
+ for (let i = 0; i < pattern.length; i++) {
199
+ const ch = pattern[i];
200
+ const next = pattern[i + 1];
201
+ if (ch === "\\" && (next === "%" || next === "_" || next === "\\")) {
202
+ out += next === "\\" ? "\\\\" : next;
203
+ i++;
204
+ continue;
205
+ }
206
+ if (ch === "%") out += ".*";
207
+ else if (ch === "_") out += ".";
208
+ else if (/[.*+?^${}()|[\]\\]/.test(ch)) out += `\\${ch}`;
209
+ else out += ch;
210
+ }
195
211
  return out;
196
212
  }
197
213
  //#endregion
@@ -0,0 +1,462 @@
1
+ //#region src/idempotency/index.d.ts
2
+ /**
3
+ * Idempotency — the ONE contract for "run this command at most once per key".
4
+ *
5
+ * **To make a command idempotent, call {@link runIdempotent} with an
6
+ * {@link IdempotencyClaimStore}.** Never hand-roll a "done" flag, a unique index
7
+ * checked after the side effect, or a per-module claims table: each of those
8
+ * has shipped here, and each double-applied or blocked on a crash without
9
+ * erroring.
10
+ *
11
+ * | layer | owns |
12
+ * |---|---|
13
+ * | this module | identity, fingerprint, lease, the decision, the store PORT, the runner |
14
+ * | a persistence kit (`mongokit`, …) | a store — must pass `runIdempotencyStoreConformance` (`./testing`) |
15
+ * | arc | HTTP: the header, required keys, response replay |
16
+ * | a kernel / spine module | the operation name and scope (`pos.checkout`, `{ organizationId }`) |
17
+ *
18
+ * Lives in repo-core for the reason `./lock` does: the store contract is driver-free — any
19
+ * store with a unique key and a conditional update implements it — and every kit already
20
+ * depends on this package.
21
+ *
22
+ * ## The three-valued outcome is the point
23
+ *
24
+ * {@link ClaimOutcome} is `claimed | replayed | in_flight`, and **`in_flight` is
25
+ * not a failure.** A concurrent attempt holding a live lease means the answer is
26
+ * *not yet known*, exactly like a provider timeout: the work may be about to
27
+ * succeed. Mapping it onto an error licenses the caller to retry the mutation,
28
+ * and if the first attempt lands that is a double-apply — the same asymmetry
29
+ * that makes `unknown` a required payment outcome. The correct handling is to
30
+ * wait out {@link InFlightDecision.retryAfterMs} and ask again, never to
31
+ * re-execute.
32
+ *
33
+ * ## Two identifiers that must not be confused
34
+ *
35
+ * - **`key`** — caller-supplied, STABLE across retries of one logical command.
36
+ * Derive it deterministically; a random fallback defeats deduplication while
37
+ * looking correctly wired.
38
+ * - **`leaseToken`** — minted fresh per ATTEMPT, and deliberately random. It
39
+ * answers "am I still the attempt that owns this claim?", so two attempts
40
+ * must never mint the same one. Deriving it would make a takeover
41
+ * indistinguishable from the attempt it replaced.
42
+ */
43
+ /** Deepest body {@link fingerprintRequest} accepts — MongoDB's own document limit. */
44
+ declare const MAX_FINGERPRINT_DEPTH = 100;
45
+ /** Default crash-window lease, matching `@classytic/cart`'s proven 30s. */
46
+ declare const DEFAULT_LEASE_MS = 30000;
47
+ /**
48
+ * Persisted state of a claim.
49
+ *
50
+ * `failed` is TERMINAL and replayable: a command that failed deterministically
51
+ * must fail the same way on retry, or the caller learns a different answer to
52
+ * the same question. It is not a licence to re-execute.
53
+ */
54
+ type ClaimState = 'in_flight' | 'succeeded' | 'failed';
55
+ /**
56
+ * What a claim attempt is allowed to do next.
57
+ *
58
+ * - `claimed` — this attempt owns the claim; EXECUTE the command.
59
+ * - `replayed` — a terminal record exists; RETURN its stored result, execute nothing.
60
+ * - `in_flight` — another live attempt owns it; WAIT and re-ask. **Not a failure.**
61
+ */
62
+ type ClaimOutcome = 'claimed' | 'replayed' | 'in_flight';
63
+ /** The stored outcome of a completed command — replayed verbatim to retries. */
64
+ type ClaimResult<TValue = unknown> = {
65
+ readonly status: 'succeeded';
66
+ readonly value: TValue;
67
+ } | {
68
+ readonly status: 'failed';
69
+ readonly error: ClaimError;
70
+ };
71
+ /**
72
+ * A replayable failure. `code` is a CLOSED host vocabulary, never a raw vendor
73
+ * string — this value is persisted and returned to callers.
74
+ */
75
+ interface ClaimError {
76
+ readonly code: string;
77
+ readonly message: string;
78
+ /** The status the original failure carried, so a replay answers exactly as the first attempt did. */
79
+ readonly status?: number;
80
+ }
81
+ /**
82
+ * The composite identity of a claim.
83
+ *
84
+ * `key` alone is not the identity. Two different operations must never share a
85
+ * key, and a key issued for one aggregate/actor must not satisfy a command
86
+ * against another — so the operation and any scoping segments are part of what
87
+ * the unique index covers. `@classytic/cart` learned this as
88
+ * `(organizationId, cartRef, operation, actorRef, idempotencyKey)`.
89
+ */
90
+ interface IdempotencyIdentity {
91
+ /** The command this key was issued for — `'checkout.finalize'`, `'purchase.pay'`. */
92
+ readonly operation: string;
93
+ /** Caller-supplied key, stable across retries of the SAME logical command. */
94
+ readonly key: string;
95
+ /** Scoping segments — organizationId, aggregate ref, actor ref. */
96
+ readonly scope?: Readonly<Record<string, string>>;
97
+ }
98
+ /**
99
+ * A claim record. The adapter persists exactly these fields (plus whatever it
100
+ * needs for TTL), with a unique index over {@link IdempotencyIdentity}.
101
+ */
102
+ interface IdempotencyClaim<TValue = unknown> {
103
+ readonly identity: IdempotencyIdentity;
104
+ /**
105
+ * Digest of the request body this key was first used with. A retry that
106
+ * presents the same key with a DIFFERENT body is not a retry — see
107
+ * {@link decideClaim}.
108
+ */
109
+ readonly requestFingerprint: string;
110
+ readonly state: ClaimState;
111
+ /** Which ATTEMPT currently owns the claim. Rotates on takeover. */
112
+ readonly leaseToken: string;
113
+ /** After this instant the lease is dead and another attempt may take over. */
114
+ readonly leaseExpiresAt: Date;
115
+ readonly createdAt: Date;
116
+ /** Set when `state` becomes terminal. */
117
+ readonly completedAt?: Date;
118
+ /** Present iff `state` is terminal. Absent on `in_flight`. */
119
+ readonly result?: ClaimResult<TValue>;
120
+ /** Monotonic attempt counter — each successful takeover increments it. */
121
+ readonly attempts: number;
122
+ /**
123
+ * RECOVERY POINT — what an attempt has durably done so far (`{ orderNumber }` once the
124
+ * order is persisted). Carried across a takeover so the next attempt RESUMES from it
125
+ * instead of repeating a side effect that already landed.
126
+ */
127
+ readonly progress?: Readonly<Record<string, unknown>>;
128
+ /**
129
+ * What the command was asked to do (`{ orderNumber, amount }`) — written on the FIRST claim
130
+ * and never changed, so a recovery sweep can re-drive a lapsed claim from the store alone.
131
+ */
132
+ readonly context?: Readonly<Record<string, unknown>>;
133
+ /** The last AMBIGUOUS failure — for the sweep and for an operator reading a stuck claim. */
134
+ readonly lastError?: string;
135
+ }
136
+ type IdempotencyErrorCode =
137
+ /** Same key, different request body. Never a retry — two commands sharing one key. */
138
+ 'FINGERPRINT_MISMATCH' |
139
+ /** A terminal claim carrying no stored result. Unreplayable; must not answer "ok". */
140
+ 'MISSING_REPLAY' |
141
+ /** The attempt no longer owns the lease — another attempt took over. */
142
+ 'LEASE_LOST' |
143
+ /** Completing / renewing a claim that already reached a terminal state. */
144
+ 'ALREADY_TERMINAL' |
145
+ /** Malformed input (empty key, non-positive lease, invalid instant). */
146
+ 'INVALID_CLAIM' |
147
+ /** A body nested past {@link MAX_FINGERPRINT_DEPTH} — refused, never truncated or recursed. */
148
+ 'UNFINGERPRINTABLE';
149
+ declare class IdempotencyError extends Error {
150
+ readonly name = "IdempotencyError";
151
+ readonly code: IdempotencyErrorCode;
152
+ constructor(code: IdempotencyErrorCode, message: string);
153
+ }
154
+ /**
155
+ * The deterministic string form of an identity — the unique key a store indexes.
156
+ *
157
+ * JSON of `[operation, key, [[scopeKey, value], …]]` with scope keys in CODE-POINT order.
158
+ * Never a delimiter join (`{ ref: 'a:b' }` and `{ ref: 'a', x: 'b' }` would collide) and
159
+ * never a locale-aware sort: a key that moves when a server's locale changes lets a retry miss
160
+ * its claim and execute again.
161
+ */
162
+ declare function identityKey(identity: IdempotencyIdentity): string;
163
+ /** True when two identities address the same claim. */
164
+ declare function sameIdentity(a: IdempotencyIdentity, b: IdempotencyIdentity): boolean;
165
+ /**
166
+ * Fingerprint a request body — the canonical digest of what the WIRE carries.
167
+ *
168
+ * Key-order independent, and wire-equivalent: an `undefined` property is absent, a `Date`
169
+ * is its ISO string, an object is its `toJSON` — so a body a caller built in memory
170
+ * fingerprints like the same body received over HTTP. Use the WHOLE body that decides the
171
+ * mutation; a subset lets two different commands share a fingerprint.
172
+ *
173
+ * Throws `UNFINGERPRINTABLE` past {@link MAX_FINGERPRINT_DEPTH} levels — refuse that body.
174
+ */
175
+ declare function fingerprintRequest(body: unknown): string;
176
+ /**
177
+ * Mint a lease token for ONE attempt.
178
+ *
179
+ * Random on purpose — see the module docblock. This is the one identifier in
180
+ * the idempotency contract that must NOT be derived.
181
+ */
182
+ declare function newLeaseToken(): string;
183
+ /** Has the crash-window lease lapsed at `now`? Expiry is exclusive of `now`. */
184
+ declare function isLeaseExpired(claim: IdempotencyClaim<unknown>, now: Date): boolean;
185
+ /** Does `leaseToken` still own this claim at `now`? */
186
+ declare function holdsLease(claim: IdempotencyClaim<unknown>, leaseToken: string, now: Date): boolean;
187
+ interface ClaimRequest {
188
+ readonly identity: IdempotencyIdentity;
189
+ readonly requestFingerprint: string;
190
+ /** Injected clock — no ambient `new Date()`, so the crash window is testable. */
191
+ readonly now: Date;
192
+ /** Injected token for THIS attempt — see {@link newLeaseToken}. */
193
+ readonly leaseToken: string;
194
+ /** Crash-window length. Default {@link DEFAULT_LEASE_MS}. */
195
+ readonly leaseMs?: number;
196
+ /** Stored on a FRESH claim only — see {@link IdempotencyClaim.context}. */
197
+ readonly context?: Readonly<Record<string, unknown>>;
198
+ /**
199
+ * Takeover ceiling. A lapsed claim that already ran this many attempts is `exhausted`
200
+ * instead of taken over — a command whose side effect keeps failing ambiguously needs a
201
+ * person, not a sixth automatic try. Absent = unbounded.
202
+ */
203
+ readonly maxAttempts?: number;
204
+ }
205
+ /** This attempt owns the claim and must EXECUTE the command. */
206
+ interface ClaimedDecision<TValue> {
207
+ readonly outcome: 'claimed';
208
+ readonly claim: IdempotencyClaim<TValue>;
209
+ /**
210
+ * Present when this claim took over a lapsed lease — the dead attempt's
211
+ * token. Persist the takeover as a CONDITIONAL update on that token so two
212
+ * simultaneous takeovers cannot both win.
213
+ */
214
+ readonly tookOverFrom?: string;
215
+ }
216
+ /** A terminal record exists: RETURN its result, execute nothing. */
217
+ interface ReplayedDecision<TValue> {
218
+ readonly outcome: 'replayed';
219
+ readonly claim: IdempotencyClaim<TValue>;
220
+ readonly result: ClaimResult<TValue>;
221
+ }
222
+ /**
223
+ * Another attempt holds a live lease. **Not a failure** — the answer is not yet
224
+ * known. Wait `retryAfterMs` and re-ask; never re-execute.
225
+ */
226
+ interface InFlightDecision<TValue> {
227
+ readonly outcome: 'in_flight';
228
+ readonly claim: IdempotencyClaim<TValue>;
229
+ /** How long until the incumbent's lease lapses. Always > 0. */
230
+ readonly retryAfterMs: number;
231
+ }
232
+ /** A lapsed claim reached `maxAttempts`. Nothing executes — escalate it to a person. */
233
+ interface ExhaustedDecision<TValue> {
234
+ readonly outcome: 'exhausted';
235
+ readonly claim: IdempotencyClaim<TValue>;
236
+ }
237
+ type ClaimDecision<TValue = unknown> = ClaimedDecision<TValue> | ReplayedDecision<TValue> | InFlightDecision<TValue> | ExhaustedDecision<TValue>;
238
+ /**
239
+ * The whole state machine, as one pure function.
240
+ *
241
+ * | stored state | lease | decision |
242
+ * |---|---|---|
243
+ * | (none) | — | `claimed` — fresh claim |
244
+ * | `in_flight` | live | `in_flight` — wait, do NOT execute |
245
+ * | `in_flight` | lapsed | `claimed` — takeover, `tookOverFrom` set |
246
+ * | `in_flight` | lapsed, `attempts ≥ maxAttempts` | `exhausted` — escalate, do NOT execute |
247
+ * | `succeeded` / `failed` | — | `replayed` — return the stored result |
248
+ *
249
+ * Two conditions throw rather than returning a decision, because both mean the
250
+ * question itself was wrong:
251
+ *
252
+ * - **`FINGERPRINT_MISMATCH`** — the same key with a different body. That is
253
+ * two commands, not one retried command; answering either way is wrong.
254
+ * Surface it as a 4xx (Stripe returns 400 here), never as a fresh claim.
255
+ * - **`MISSING_REPLAY`** — a terminal record with no stored result. There is
256
+ * nothing to replay, and inventing an empty success is precisely the silent
257
+ * permissive default this codebase keeps getting bitten by.
258
+ */
259
+ declare function decideClaim<TValue = unknown>(existing: IdempotencyClaim<TValue> | null | undefined, request: ClaimRequest): ClaimDecision<TValue>;
260
+ /**
261
+ * Extend a live lease — for a command that legitimately outruns the crash
262
+ * window. Throws `LEASE_LOST` when the caller no longer owns the claim, so a
263
+ * superseded attempt cannot silently keep working and then write its result
264
+ * over the winner's.
265
+ */
266
+ declare function renewLease<TValue>(claim: IdempotencyClaim<TValue>, input: {
267
+ readonly leaseToken: string;
268
+ readonly now: Date;
269
+ readonly leaseMs?: number;
270
+ }): IdempotencyClaim<TValue>;
271
+ /**
272
+ * Move a claim to a terminal state with the result retries will replay.
273
+ *
274
+ * The lease check is the important part: an attempt whose lease lapsed and was
275
+ * taken over must NOT write its result. Without it, the slow attempt's answer
276
+ * overwrites the takeover's, and every subsequent retry replays an outcome that
277
+ * does not match what was actually applied.
278
+ */
279
+ declare function completeClaim<TValue>(claim: IdempotencyClaim<TValue>, input: {
280
+ readonly leaseToken: string;
281
+ readonly now: Date;
282
+ readonly result: ClaimResult<TValue>;
283
+ }): IdempotencyClaim<TValue>;
284
+ /**
285
+ * Narrowing helper for the one outcome callers keep getting wrong.
286
+ *
287
+ * `in_flight` is neither success nor failure. Route it to a 409 + `Retry-After`
288
+ * (or an internal wait), never to the command's error path.
289
+ */
290
+ declare function isInFlight<TValue>(decision: ClaimDecision<TValue>): decision is InFlightDecision<TValue>;
291
+ /**
292
+ * Persistence for claims. Every write is ATOMIC on one identity — the whole
293
+ * correctness of {@link runIdempotent} rests on these compare-and-set
294
+ * operations, so an adapter must not emulate them with read-then-write.
295
+ *
296
+ * An adapter proves itself with `@classytic/primitives/testing/idempotency-store`.
297
+ * Retention (how long a terminal claim is kept) is the adapter's configuration;
298
+ * size it to the longest a caller may retry — days for an offline till.
299
+ */
300
+ interface IdempotencyClaimStore {
301
+ /** The claim for `identity`, or `null`. */
302
+ get<TValue>(identity: IdempotencyIdentity, options?: IdempotencyStoreCallOptions): Promise<IdempotencyClaim<TValue> | null>;
303
+ /** Insert iff no claim exists for its identity. `false` = another attempt got there first. */
304
+ insert<TValue>(claim: IdempotencyClaim<TValue>, options?: IdempotencyStoreCallOptions): Promise<boolean>;
305
+ /**
306
+ * Replace the claim iff it is `in_flight` AND holds `expectedLeaseToken`.
307
+ * Used for takeover, checkpoint, lapse and completion. `false` = the lease moved on.
308
+ */
309
+ swap<TValue>(identity: IdempotencyIdentity, expectedLeaseToken: string, next: IdempotencyClaim<TValue>, options?: IdempotencyStoreCallOptions): Promise<boolean>;
310
+ /**
311
+ * Delete the claim iff it is `in_flight` AND holds `expectedLeaseToken` — frees
312
+ * the key after a TRANSIENT failure so a retry may execute. `false` = not ours.
313
+ */
314
+ release(identity: IdempotencyIdentity, expectedLeaseToken: string, options?: IdempotencyStoreCallOptions): Promise<boolean>;
315
+ /**
316
+ * In-flight claims of `operation` whose lease lapsed by `now`, oldest lease first — what a
317
+ * recovery sweep re-drives (each through `runIdempotent` with the claim's own identity and
318
+ * fingerprint, so the takeover is atomic and resumes from the stored progress).
319
+ */
320
+ listLapsed(operation: string, now: Date, limit: number): Promise<IdempotencyClaim<unknown>[]>;
321
+ }
322
+ /**
323
+ * Per-call options a store honours. `session` is the caller's open transaction: when the
324
+ * command's writes are transactional, pass it so the claim COMMITS OR ROLLS BACK WITH THEM —
325
+ * a claim committed beside a rolled-back write replays a result that does not exist.
326
+ */
327
+ interface IdempotencyStoreCallOptions {
328
+ readonly session?: unknown;
329
+ }
330
+ /**
331
+ * A replay of a command that failed TERMINALLY — the same answer the first
332
+ * attempt got. `code` / `status` are the stored {@link ClaimError}.
333
+ */
334
+ declare class IdempotentReplayError extends Error {
335
+ readonly name = "IdempotentReplayError";
336
+ readonly code: string;
337
+ readonly status?: number;
338
+ constructor(error: ClaimError);
339
+ }
340
+ /**
341
+ * What a failure of `execute` means for the claim.
342
+ *
343
+ * - `terminal` — the command definitively failed; every retry replays this error.
344
+ * - `transient` — nothing landed; the claim is released and a retry executes afresh.
345
+ * - `ambiguous` — a side effect MAY have landed (a timeout mid external call): the claim is
346
+ * kept, its lease lapsed NOW and the error recorded, so a retry or sweep takes over at once
347
+ * and resumes from the recorded progress.
348
+ *
349
+ * A `transient` failure after a checkpoint is treated as `ambiguous`: something did land.
350
+ */
351
+ type FailureDisposition = {
352
+ readonly kind: 'terminal';
353
+ readonly error: ClaimError;
354
+ } | {
355
+ readonly kind: 'transient';
356
+ } | {
357
+ readonly kind: 'ambiguous';
358
+ };
359
+ interface RunIdempotentOptions<TValue> {
360
+ readonly store: IdempotencyClaimStore;
361
+ readonly identity: IdempotencyIdentity;
362
+ /** {@link fingerprintRequest} of the WHOLE body that decides the command. */
363
+ readonly requestFingerprint: string;
364
+ /**
365
+ * The command. Runs only when this attempt owns the claim. Its return value is
366
+ * STORED and replayed, so return something serializable and small — a pointer
367
+ * (`{ orderNumber }`) the caller reloads, not the whole aggregate.
368
+ */
369
+ readonly execute: (attempt: IdempotentAttempt) => Promise<TValue>;
370
+ /**
371
+ * What the command was asked to do, stored on the FIRST claim — everything a sweep needs to
372
+ * re-drive it from the store alone. Handed back to `execute` as `attempt.context`.
373
+ */
374
+ readonly context?: Readonly<Record<string, unknown>>;
375
+ /** Takeover ceiling — past it the run is `exhausted`, see {@link ClaimRequest.maxAttempts}. */
376
+ readonly maxAttempts?: number;
377
+ /** Passed to every store call — `{ session }` to claim inside the caller's transaction. */
378
+ readonly storeOptions?: IdempotencyStoreCallOptions;
379
+ /**
380
+ * What a failure means — see {@link FailureDisposition}. Default `transient`: a validation
381
+ * refusal should be classified `terminal`, or each retry re-runs it to the same answer.
382
+ */
383
+ readonly classifyFailure?: (error: unknown) => FailureDisposition;
384
+ /**
385
+ * Crash-window length. MUST exceed the command's worst-case run time: a lease
386
+ * that lapses mid-command lets a retry take over and execute concurrently.
387
+ */
388
+ readonly leaseMs?: number;
389
+ readonly now?: () => Date;
390
+ readonly newLeaseToken?: () => string;
391
+ }
392
+ /** What `execute` is handed: which attempt it is, what it was asked, and how to record a recovery point. */
393
+ interface IdempotentAttempt {
394
+ readonly leaseToken: string;
395
+ /** 1 for the first attempt; higher after a takeover of a crashed one. */
396
+ readonly attempt: number;
397
+ /** The recovery point a crashed attempt reached — RESUME from it, do not repeat it. */
398
+ readonly progress: Readonly<Record<string, unknown>> | undefined;
399
+ /** The stored context — the FIRST attempt's, on a takeover. */
400
+ readonly context: Readonly<Record<string, unknown>> | undefined;
401
+ /**
402
+ * Durably record a recovery point (merged into the stored progress). Call it right
403
+ * after a side effect that must not be repeated — or INSIDE that side effect's transaction
404
+ * (`{ session }`), so the two commit together and no crash can separate them. Throws
405
+ * `LEASE_LOST` if another attempt took over — stop, the command is no longer yours.
406
+ */
407
+ readonly checkpoint: (progress: Readonly<Record<string, unknown>>, options?: IdempotencyStoreCallOptions) => Promise<void>;
408
+ }
409
+ type IdempotentRun<TValue> =
410
+ /** This attempt ran the command. */
411
+ {
412
+ readonly outcome: 'executed';
413
+ readonly value: TValue;
414
+ readonly attempt: number;
415
+ } |
416
+ /** A previous attempt succeeded; its stored value, nothing executed. */
417
+ {
418
+ readonly outcome: 'replayed';
419
+ readonly value: TValue;
420
+ } |
421
+ /**
422
+ * Another attempt is running it. **Not a failure** — answer 409 + `Retry-After`, or wait and
423
+ * re-run. `progress` is what it has durably done so far.
424
+ */
425
+ {
426
+ readonly outcome: 'in_flight';
427
+ readonly retryAfterMs: number;
428
+ readonly progress: Readonly<Record<string, unknown>> | undefined;
429
+ } |
430
+ /** `maxAttempts` spent on a command that keeps failing ambiguously. Nothing ran — escalate. */
431
+ {
432
+ readonly outcome: 'exhausted';
433
+ readonly attempts: number;
434
+ readonly progress: Readonly<Record<string, unknown>> | undefined;
435
+ readonly lastError: string | undefined;
436
+ };
437
+ /**
438
+ * Run `execute` at most once per identity: claim → execute → complete.
439
+ *
440
+ * - a first attempt, or a takeover of a lapsed lease → executes (a takeover resumes from
441
+ * the stored progress and context);
442
+ * - a completed success → replays the stored value;
443
+ * - a completed terminal failure → throws {@link IdempotentReplayError};
444
+ * - a live attempt elsewhere → `in_flight`, never an execution;
445
+ * - a lapsed claim past `maxAttempts` → `exhausted`, never an execution;
446
+ * - the same key with a different body → throws `FINGERPRINT_MISMATCH`.
447
+ *
448
+ * A failure of `execute` is rethrown unchanged after the claim is updated per its
449
+ * {@link FailureDisposition}. A completion that finds the lease taken over throws
450
+ * `LEASE_LOST`: the work WAS applied, so saying "ok" would hide that two attempts ran.
451
+ */
452
+ declare function runIdempotent<TValue>(options: RunIdempotentOptions<TValue>): Promise<IdempotentRun<TValue>>;
453
+ /**
454
+ * In-memory {@link IdempotencyClaimStore} — tests, scripts, single-process tools. Not for a
455
+ * server: claims die with the process, so a retry after a restart executes again.
456
+ */
457
+ declare function createMemoryIdempotencyStore(): IdempotencyClaimStore & {
458
+ /** Every stored claim — for assertions. */
459
+ readonly claims: () => IdempotencyClaim<unknown>[];
460
+ };
461
+ //#endregion
462
+ export { ClaimDecision, ClaimError, ClaimOutcome, ClaimRequest, ClaimResult, ClaimState, ClaimedDecision, DEFAULT_LEASE_MS, ExhaustedDecision, FailureDisposition, IdempotencyClaim, IdempotencyClaimStore, IdempotencyError, IdempotencyErrorCode, IdempotencyIdentity, IdempotencyStoreCallOptions, IdempotentAttempt, IdempotentReplayError, IdempotentRun, InFlightDecision, MAX_FINGERPRINT_DEPTH, ReplayedDecision, RunIdempotentOptions, completeClaim, createMemoryIdempotencyStore, decideClaim, fingerprintRequest, holdsLease, identityKey, isInFlight, isLeaseExpired, newLeaseToken, renewLease, runIdempotent, sameIdentity };