@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.
- package/CHANGELOG.md +1290 -1098
- package/dist/cache/engine.d.mts +7 -5
- package/dist/cache/engine.mjs +34 -16
- package/dist/cache/memo.d.mts +4 -0
- package/dist/cache/memo.mjs +17 -4
- package/dist/cache/memory-adapter.mjs +6 -4
- package/dist/cache/plugin/read-hooks.mjs +1 -1
- package/dist/cache/stable-stringify.d.mts +7 -8
- package/dist/cache/stable-stringify.mjs +14 -11
- package/dist/cache/tag-index.mjs +1 -2
- package/dist/cache/types.d.mts +4 -3
- package/dist/errors/codes.d.mts +117 -0
- package/dist/errors/codes.mjs +114 -0
- package/dist/errors/contract.d.mts +2 -1
- package/dist/errors/contract.mjs +9 -3
- package/dist/errors/index.d.mts +3 -2
- package/dist/errors/index.mjs +2 -2
- package/dist/errors/types.d.mts +3 -30
- package/dist/filter/builders.mjs +1 -1
- package/dist/filter/match-record.mjs +9 -3
- package/dist/filter/match.mjs +25 -9
- package/dist/idempotency/index.d.mts +462 -0
- package/dist/idempotency/index.mjs +419 -0
- package/dist/pagination/types.d.mts +6 -0
- package/dist/query-parser/coerce.d.mts +18 -11
- package/dist/query-parser/coerce.mjs +37 -69
- package/dist/query-parser/errors.d.mts +30 -0
- package/dist/query-parser/errors.mjs +32 -0
- package/dist/query-parser/grammar.d.mts +107 -0
- package/dist/query-parser/grammar.mjs +462 -0
- package/dist/query-parser/index.d.mts +5 -2
- package/dist/query-parser/index.mjs +5 -2
- package/dist/query-parser/parse-url.d.mts +1 -1
- package/dist/query-parser/parse-url.mjs +72 -205
- package/dist/query-parser/regex-safety.d.mts +31 -0
- package/dist/query-parser/regex-safety.mjs +151 -0
- package/dist/query-parser/reserved.mjs +1 -0
- package/dist/query-parser/types.d.mts +15 -25
- package/dist/repository/types.d.mts +6 -5
- package/dist/schema/field-rules.mjs +49 -6
- package/dist/schema/standard-schema.mjs +1 -1
- package/dist/sync/index.d.mts +90 -55
- package/dist/sync/index.mjs +0 -0
- package/dist/testing/change-log-conformance.d.mts +21 -0
- package/dist/testing/change-log-conformance.mjs +204 -0
- package/dist/testing/command-stream-conformance.d.mts +15 -0
- package/dist/testing/command-stream-conformance.mjs +163 -0
- package/dist/testing/idempotency-conformance.d.mts +15 -0
- package/dist/testing/idempotency-conformance.mjs +315 -0
- package/dist/testing/index.d.mts +5 -1
- package/dist/testing/index.mjs +5 -1
- package/dist/testing/query-grammar-conformance.d.mts +60 -0
- package/dist/testing/query-grammar-conformance.mjs +380 -0
- package/package.json +6 -2
- package/dist/errors/types.mjs +0 -29
package/dist/filter/builders.mjs
CHANGED
|
@@ -166,7 +166,7 @@ function raw(sql, params = []) {
|
|
|
166
166
|
});
|
|
167
167
|
}
|
|
168
168
|
function escapeLikePattern(value) {
|
|
169
|
-
return value.replace(/[
|
|
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,
|
|
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
|
-
/**
|
|
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) :
|
|
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));
|
package/dist/filter/match.mjs
CHANGED
|
@@ -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":
|
|
11
|
-
|
|
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":
|
|
17
|
-
|
|
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`
|
|
195
|
+
/** SQL `LIKE` → regex body: `%` → `.*`, `_` → `.`, and the `\` escape (`\%`, `\_`, `\\`) as every kit compiles it. */
|
|
189
196
|
function likeToRegex(pattern) {
|
|
190
197
|
let out = "";
|
|
191
|
-
for (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 };
|