@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
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { contentHash } from "../hash/index.mjs";
|
|
2
|
+
//#region src/idempotency/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Idempotency — the ONE contract for "run this command at most once per key".
|
|
5
|
+
*
|
|
6
|
+
* **To make a command idempotent, call {@link runIdempotent} with an
|
|
7
|
+
* {@link IdempotencyClaimStore}.** Never hand-roll a "done" flag, a unique index
|
|
8
|
+
* checked after the side effect, or a per-module claims table: each of those
|
|
9
|
+
* has shipped here, and each double-applied or blocked on a crash without
|
|
10
|
+
* erroring.
|
|
11
|
+
*
|
|
12
|
+
* | layer | owns |
|
|
13
|
+
* |---|---|
|
|
14
|
+
* | this module | identity, fingerprint, lease, the decision, the store PORT, the runner |
|
|
15
|
+
* | a persistence kit (`mongokit`, …) | a store — must pass `runIdempotencyStoreConformance` (`./testing`) |
|
|
16
|
+
* | arc | HTTP: the header, required keys, response replay |
|
|
17
|
+
* | a kernel / spine module | the operation name and scope (`pos.checkout`, `{ organizationId }`) |
|
|
18
|
+
*
|
|
19
|
+
* Lives in repo-core for the reason `./lock` does: the store contract is driver-free — any
|
|
20
|
+
* store with a unique key and a conditional update implements it — and every kit already
|
|
21
|
+
* depends on this package.
|
|
22
|
+
*
|
|
23
|
+
* ## The three-valued outcome is the point
|
|
24
|
+
*
|
|
25
|
+
* {@link ClaimOutcome} is `claimed | replayed | in_flight`, and **`in_flight` is
|
|
26
|
+
* not a failure.** A concurrent attempt holding a live lease means the answer is
|
|
27
|
+
* *not yet known*, exactly like a provider timeout: the work may be about to
|
|
28
|
+
* succeed. Mapping it onto an error licenses the caller to retry the mutation,
|
|
29
|
+
* and if the first attempt lands that is a double-apply — the same asymmetry
|
|
30
|
+
* that makes `unknown` a required payment outcome. The correct handling is to
|
|
31
|
+
* wait out {@link InFlightDecision.retryAfterMs} and ask again, never to
|
|
32
|
+
* re-execute.
|
|
33
|
+
*
|
|
34
|
+
* ## Two identifiers that must not be confused
|
|
35
|
+
*
|
|
36
|
+
* - **`key`** — caller-supplied, STABLE across retries of one logical command.
|
|
37
|
+
* Derive it deterministically; a random fallback defeats deduplication while
|
|
38
|
+
* looking correctly wired.
|
|
39
|
+
* - **`leaseToken`** — minted fresh per ATTEMPT, and deliberately random. It
|
|
40
|
+
* answers "am I still the attempt that owns this claim?", so two attempts
|
|
41
|
+
* must never mint the same one. Deriving it would make a takeover
|
|
42
|
+
* indistinguishable from the attempt it replaced.
|
|
43
|
+
*/
|
|
44
|
+
/** Deepest body {@link fingerprintRequest} accepts — MongoDB's own document limit. */
|
|
45
|
+
const MAX_FINGERPRINT_DEPTH = 100;
|
|
46
|
+
/** Default crash-window lease, matching `@classytic/cart`'s proven 30s. */
|
|
47
|
+
const DEFAULT_LEASE_MS = 3e4;
|
|
48
|
+
var IdempotencyError = class extends Error {
|
|
49
|
+
name = "IdempotencyError";
|
|
50
|
+
code;
|
|
51
|
+
constructor(code, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.code = code;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The deterministic string form of an identity — the unique key a store indexes.
|
|
58
|
+
*
|
|
59
|
+
* JSON of `[operation, key, [[scopeKey, value], …]]` with scope keys in CODE-POINT order.
|
|
60
|
+
* Never a delimiter join (`{ ref: 'a:b' }` and `{ ref: 'a', x: 'b' }` would collide) and
|
|
61
|
+
* never a locale-aware sort: a key that moves when a server's locale changes lets a retry miss
|
|
62
|
+
* its claim and execute again.
|
|
63
|
+
*/
|
|
64
|
+
function identityKey(identity) {
|
|
65
|
+
assertNonEmpty(identity.operation, "identity.operation");
|
|
66
|
+
assertNonEmpty(identity.key, "identity.key");
|
|
67
|
+
const scope = identity.scope ?? {};
|
|
68
|
+
const pairs = Object.keys(scope).sort().map((k) => [k, scope[k]]);
|
|
69
|
+
return JSON.stringify([
|
|
70
|
+
identity.operation,
|
|
71
|
+
identity.key,
|
|
72
|
+
pairs
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
/** True when two identities address the same claim. */
|
|
76
|
+
function sameIdentity(a, b) {
|
|
77
|
+
return identityKey(a) === identityKey(b);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Fingerprint a request body — the canonical digest of what the WIRE carries.
|
|
81
|
+
*
|
|
82
|
+
* Key-order independent, and wire-equivalent: an `undefined` property is absent, a `Date`
|
|
83
|
+
* is its ISO string, an object is its `toJSON` — so a body a caller built in memory
|
|
84
|
+
* fingerprints like the same body received over HTTP. Use the WHOLE body that decides the
|
|
85
|
+
* mutation; a subset lets two different commands share a fingerprint.
|
|
86
|
+
*
|
|
87
|
+
* Throws `UNFINGERPRINTABLE` past {@link MAX_FINGERPRINT_DEPTH} levels — refuse that body.
|
|
88
|
+
*/
|
|
89
|
+
function fingerprintRequest(body) {
|
|
90
|
+
return contentHash(toWire(body === void 0 ? null : body, 0));
|
|
91
|
+
}
|
|
92
|
+
/** JSON's projection (`undefined` dropped / `null` in arrays, `toJSON` honoured), depth-bounded. */
|
|
93
|
+
function toWire(value, depth) {
|
|
94
|
+
if (value === null || typeof value !== "object") return value;
|
|
95
|
+
if (depth >= 100) throw new IdempotencyError("UNFINGERPRINTABLE", `request body nested deeper than 100 levels`);
|
|
96
|
+
const json = value.toJSON;
|
|
97
|
+
if (typeof json === "function") return toWire(json.call(value), depth);
|
|
98
|
+
if (Array.isArray(value)) return value.map((v) => v === void 0 ? null : toWire(v, depth + 1));
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const [k, v] of Object.entries(value)) if (v !== void 0) out[k] = toWire(v, depth + 1);
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Mint a lease token for ONE attempt.
|
|
105
|
+
*
|
|
106
|
+
* Random on purpose — see the module docblock. This is the one identifier in
|
|
107
|
+
* the idempotency contract that must NOT be derived.
|
|
108
|
+
*/
|
|
109
|
+
function newLeaseToken() {
|
|
110
|
+
const c = globalThis.crypto;
|
|
111
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
112
|
+
return `lease_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
113
|
+
}
|
|
114
|
+
/** Has the crash-window lease lapsed at `now`? Expiry is exclusive of `now`. */
|
|
115
|
+
function isLeaseExpired(claim, now) {
|
|
116
|
+
return now.getTime() >= claim.leaseExpiresAt.getTime();
|
|
117
|
+
}
|
|
118
|
+
/** Does `leaseToken` still own this claim at `now`? */
|
|
119
|
+
function holdsLease(claim, leaseToken, now) {
|
|
120
|
+
return claim.leaseToken === leaseToken && !isLeaseExpired(claim, now);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The whole state machine, as one pure function.
|
|
124
|
+
*
|
|
125
|
+
* | stored state | lease | decision |
|
|
126
|
+
* |---|---|---|
|
|
127
|
+
* | (none) | — | `claimed` — fresh claim |
|
|
128
|
+
* | `in_flight` | live | `in_flight` — wait, do NOT execute |
|
|
129
|
+
* | `in_flight` | lapsed | `claimed` — takeover, `tookOverFrom` set |
|
|
130
|
+
* | `in_flight` | lapsed, `attempts ≥ maxAttempts` | `exhausted` — escalate, do NOT execute |
|
|
131
|
+
* | `succeeded` / `failed` | — | `replayed` — return the stored result |
|
|
132
|
+
*
|
|
133
|
+
* Two conditions throw rather than returning a decision, because both mean the
|
|
134
|
+
* question itself was wrong:
|
|
135
|
+
*
|
|
136
|
+
* - **`FINGERPRINT_MISMATCH`** — the same key with a different body. That is
|
|
137
|
+
* two commands, not one retried command; answering either way is wrong.
|
|
138
|
+
* Surface it as a 4xx (Stripe returns 400 here), never as a fresh claim.
|
|
139
|
+
* - **`MISSING_REPLAY`** — a terminal record with no stored result. There is
|
|
140
|
+
* nothing to replay, and inventing an empty success is precisely the silent
|
|
141
|
+
* permissive default this codebase keeps getting bitten by.
|
|
142
|
+
*/
|
|
143
|
+
function decideClaim(existing, request) {
|
|
144
|
+
assertNonEmpty(request.requestFingerprint, "request.requestFingerprint");
|
|
145
|
+
assertNonEmpty(request.leaseToken, "request.leaseToken");
|
|
146
|
+
assertInstant(request.now, "request.now");
|
|
147
|
+
const leaseMs = request.leaseMs ?? 3e4;
|
|
148
|
+
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.`);
|
|
149
|
+
if (existing == null) return {
|
|
150
|
+
outcome: "claimed",
|
|
151
|
+
claim: mintClaim(request, leaseMs, 1)
|
|
152
|
+
};
|
|
153
|
+
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.`);
|
|
154
|
+
if (existing.state === "succeeded" || existing.state === "failed") {
|
|
155
|
+
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.`);
|
|
156
|
+
return {
|
|
157
|
+
outcome: "replayed",
|
|
158
|
+
claim: existing,
|
|
159
|
+
result: existing.result
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (!isLeaseExpired(existing, request.now)) return {
|
|
163
|
+
outcome: "in_flight",
|
|
164
|
+
claim: existing,
|
|
165
|
+
retryAfterMs: existing.leaseExpiresAt.getTime() - request.now.getTime()
|
|
166
|
+
};
|
|
167
|
+
if (request.maxAttempts !== void 0 && existing.attempts >= request.maxAttempts) return {
|
|
168
|
+
outcome: "exhausted",
|
|
169
|
+
claim: existing
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
outcome: "claimed",
|
|
173
|
+
claim: {
|
|
174
|
+
...mintClaim(withoutContext(request), leaseMs, existing.attempts + 1),
|
|
175
|
+
createdAt: existing.createdAt,
|
|
176
|
+
...existing.progress !== void 0 ? { progress: existing.progress } : {},
|
|
177
|
+
...existing.context !== void 0 ? { context: existing.context } : {},
|
|
178
|
+
...existing.lastError !== void 0 ? { lastError: existing.lastError } : {}
|
|
179
|
+
},
|
|
180
|
+
tookOverFrom: existing.leaseToken
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** A takeover keeps the FIRST attempt's context, so the new request's is dropped. */
|
|
184
|
+
function withoutContext(request) {
|
|
185
|
+
const { context: _ignored, ...rest } = request;
|
|
186
|
+
return rest;
|
|
187
|
+
}
|
|
188
|
+
function mintClaim(request, leaseMs, attempts) {
|
|
189
|
+
identityKey(request.identity);
|
|
190
|
+
return {
|
|
191
|
+
identity: request.identity,
|
|
192
|
+
requestFingerprint: request.requestFingerprint,
|
|
193
|
+
state: "in_flight",
|
|
194
|
+
leaseToken: request.leaseToken,
|
|
195
|
+
leaseExpiresAt: new Date(request.now.getTime() + leaseMs),
|
|
196
|
+
createdAt: request.now,
|
|
197
|
+
...request.context !== void 0 ? { context: request.context } : {},
|
|
198
|
+
attempts
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Extend a live lease — for a command that legitimately outruns the crash
|
|
203
|
+
* window. Throws `LEASE_LOST` when the caller no longer owns the claim, so a
|
|
204
|
+
* superseded attempt cannot silently keep working and then write its result
|
|
205
|
+
* over the winner's.
|
|
206
|
+
*/
|
|
207
|
+
function renewLease(claim, input) {
|
|
208
|
+
assertInstant(input.now, "now");
|
|
209
|
+
if (claim.state !== "in_flight") throw new IdempotencyError("ALREADY_TERMINAL", `cannot renew a ${claim.state} claim`);
|
|
210
|
+
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.`);
|
|
211
|
+
return {
|
|
212
|
+
...claim,
|
|
213
|
+
leaseExpiresAt: new Date(input.now.getTime() + (input.leaseMs ?? 3e4))
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Move a claim to a terminal state with the result retries will replay.
|
|
218
|
+
*
|
|
219
|
+
* The lease check is the important part: an attempt whose lease lapsed and was
|
|
220
|
+
* taken over must NOT write its result. Without it, the slow attempt's answer
|
|
221
|
+
* overwrites the takeover's, and every subsequent retry replays an outcome that
|
|
222
|
+
* does not match what was actually applied.
|
|
223
|
+
*/
|
|
224
|
+
function completeClaim(claim, input) {
|
|
225
|
+
assertInstant(input.now, "now");
|
|
226
|
+
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.`);
|
|
227
|
+
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.");
|
|
228
|
+
return {
|
|
229
|
+
...claim,
|
|
230
|
+
state: input.result.status,
|
|
231
|
+
result: input.result,
|
|
232
|
+
completedAt: input.now
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Narrowing helper for the one outcome callers keep getting wrong.
|
|
237
|
+
*
|
|
238
|
+
* `in_flight` is neither success nor failure. Route it to a 409 + `Retry-After`
|
|
239
|
+
* (or an internal wait), never to the command's error path.
|
|
240
|
+
*/
|
|
241
|
+
function isInFlight(decision) {
|
|
242
|
+
return decision.outcome === "in_flight";
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* A replay of a command that failed TERMINALLY — the same answer the first
|
|
246
|
+
* attempt got. `code` / `status` are the stored {@link ClaimError}.
|
|
247
|
+
*/
|
|
248
|
+
var IdempotentReplayError = class extends Error {
|
|
249
|
+
name = "IdempotentReplayError";
|
|
250
|
+
code;
|
|
251
|
+
status;
|
|
252
|
+
constructor(error) {
|
|
253
|
+
super(error.message);
|
|
254
|
+
this.code = error.code;
|
|
255
|
+
if (error.status !== void 0) this.status = error.status;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
/** Bounded so a store that never lets an insert or swap win cannot spin forever. */
|
|
259
|
+
const MAX_CLAIM_ROUNDS = 5;
|
|
260
|
+
/**
|
|
261
|
+
* Run `execute` at most once per identity: claim → execute → complete.
|
|
262
|
+
*
|
|
263
|
+
* - a first attempt, or a takeover of a lapsed lease → executes (a takeover resumes from
|
|
264
|
+
* the stored progress and context);
|
|
265
|
+
* - a completed success → replays the stored value;
|
|
266
|
+
* - a completed terminal failure → throws {@link IdempotentReplayError};
|
|
267
|
+
* - a live attempt elsewhere → `in_flight`, never an execution;
|
|
268
|
+
* - a lapsed claim past `maxAttempts` → `exhausted`, never an execution;
|
|
269
|
+
* - the same key with a different body → throws `FINGERPRINT_MISMATCH`.
|
|
270
|
+
*
|
|
271
|
+
* A failure of `execute` is rethrown unchanged after the claim is updated per its
|
|
272
|
+
* {@link FailureDisposition}. A completion that finds the lease taken over throws
|
|
273
|
+
* `LEASE_LOST`: the work WAS applied, so saying "ok" would hide that two attempts ran.
|
|
274
|
+
*/
|
|
275
|
+
async function runIdempotent(options) {
|
|
276
|
+
const { store, identity, requestFingerprint, storeOptions } = options;
|
|
277
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
278
|
+
const mintToken = options.newLeaseToken ?? newLeaseToken;
|
|
279
|
+
let claim;
|
|
280
|
+
for (let round = 0; round < MAX_CLAIM_ROUNDS && !claim; round++) {
|
|
281
|
+
const decision = decideClaim(await store.get(identity, storeOptions), {
|
|
282
|
+
identity,
|
|
283
|
+
requestFingerprint,
|
|
284
|
+
now: now(),
|
|
285
|
+
leaseToken: mintToken(),
|
|
286
|
+
...options.leaseMs !== void 0 ? { leaseMs: options.leaseMs } : {},
|
|
287
|
+
...options.context !== void 0 ? { context: options.context } : {},
|
|
288
|
+
...options.maxAttempts !== void 0 ? { maxAttempts: options.maxAttempts } : {}
|
|
289
|
+
});
|
|
290
|
+
if (decision.outcome === "in_flight") return {
|
|
291
|
+
outcome: "in_flight",
|
|
292
|
+
retryAfterMs: decision.retryAfterMs,
|
|
293
|
+
progress: decision.claim.progress
|
|
294
|
+
};
|
|
295
|
+
if (decision.outcome === "exhausted") return {
|
|
296
|
+
outcome: "exhausted",
|
|
297
|
+
attempts: decision.claim.attempts,
|
|
298
|
+
progress: decision.claim.progress,
|
|
299
|
+
lastError: decision.claim.lastError
|
|
300
|
+
};
|
|
301
|
+
if (decision.outcome === "replayed") {
|
|
302
|
+
if (decision.result.status === "failed") throw new IdempotentReplayError(decision.result.error);
|
|
303
|
+
return {
|
|
304
|
+
outcome: "replayed",
|
|
305
|
+
value: decision.result.value
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (decision.tookOverFrom === void 0 ? await store.insert(decision.claim, storeOptions) : await store.swap(identity, decision.tookOverFrom, decision.claim, storeOptions)) claim = decision.claim;
|
|
309
|
+
}
|
|
310
|
+
if (!claim) throw new IdempotencyError("INVALID_CLAIM", `could not claim '${identity.key}' in ${MAX_CLAIM_ROUNDS} rounds — the store keeps refusing both insert and swap, which a conforming store cannot do.`);
|
|
311
|
+
const { leaseToken } = claim;
|
|
312
|
+
let current = claim;
|
|
313
|
+
const checkpoint = async (progress, callOptions) => {
|
|
314
|
+
const next = {
|
|
315
|
+
...current,
|
|
316
|
+
progress: {
|
|
317
|
+
...current.progress,
|
|
318
|
+
...progress
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
if (!await store.swap(identity, leaseToken, next, callOptions ?? storeOptions)) throw new IdempotencyError("LEASE_LOST", `'${identity.key}' was taken over by another attempt — stop, the command is no longer this attempt's.`);
|
|
322
|
+
current = next;
|
|
323
|
+
};
|
|
324
|
+
let value;
|
|
325
|
+
try {
|
|
326
|
+
value = await options.execute({
|
|
327
|
+
leaseToken,
|
|
328
|
+
attempt: claim.attempts,
|
|
329
|
+
progress: claim.progress,
|
|
330
|
+
context: claim.context,
|
|
331
|
+
checkpoint
|
|
332
|
+
});
|
|
333
|
+
} catch (error) {
|
|
334
|
+
let disposition = options.classifyFailure?.(error) ?? { kind: "transient" };
|
|
335
|
+
if (disposition.kind === "transient" && current.progress !== void 0) disposition = { kind: "ambiguous" };
|
|
336
|
+
try {
|
|
337
|
+
if (disposition.kind === "terminal") {
|
|
338
|
+
const failed = completeClaim(current, {
|
|
339
|
+
leaseToken,
|
|
340
|
+
now: now(),
|
|
341
|
+
result: {
|
|
342
|
+
status: "failed",
|
|
343
|
+
error: disposition.error
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
await store.swap(identity, leaseToken, failed, storeOptions);
|
|
347
|
+
} else if (disposition.kind === "ambiguous") {
|
|
348
|
+
const lapsed = {
|
|
349
|
+
...current,
|
|
350
|
+
leaseExpiresAt: now(),
|
|
351
|
+
lastError: errorMessage(error)
|
|
352
|
+
};
|
|
353
|
+
await store.swap(identity, leaseToken, lapsed, storeOptions);
|
|
354
|
+
} else await store.release(identity, leaseToken, storeOptions);
|
|
355
|
+
} catch {}
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
const done = completeClaim(current, {
|
|
359
|
+
leaseToken,
|
|
360
|
+
now: now(),
|
|
361
|
+
result: {
|
|
362
|
+
status: "succeeded",
|
|
363
|
+
value
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
if (!await store.swap(identity, leaseToken, done, storeOptions)) throw new IdempotencyError("LEASE_LOST", `'${identity.key}' executed, but its lease was taken over before it could complete — the command may have run twice. Raise leaseMs above the command's run time.`);
|
|
367
|
+
return {
|
|
368
|
+
outcome: "executed",
|
|
369
|
+
value,
|
|
370
|
+
attempt: claim.attempts
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function errorMessage(error) {
|
|
374
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
375
|
+
}
|
|
376
|
+
function assertNonEmpty(value, label) {
|
|
377
|
+
if (typeof value !== "string" || value.length === 0) throw new IdempotencyError("INVALID_CLAIM", `${label} must be a non-empty string, got ${String(value)}`);
|
|
378
|
+
}
|
|
379
|
+
function assertInstant(value, label) {
|
|
380
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) throw new IdempotencyError("INVALID_CLAIM", `${label} must be a valid Date, got ${String(value)}`);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* In-memory {@link IdempotencyClaimStore} — tests, scripts, single-process tools. Not for a
|
|
384
|
+
* server: claims die with the process, so a retry after a restart executes again.
|
|
385
|
+
*/
|
|
386
|
+
function createMemoryIdempotencyStore() {
|
|
387
|
+
const rows = /* @__PURE__ */ new Map();
|
|
388
|
+
const ownedInFlight = (identity, token) => {
|
|
389
|
+
const row = rows.get(identityKey(identity));
|
|
390
|
+
return row !== void 0 && row.state === "in_flight" && row.leaseToken === token;
|
|
391
|
+
};
|
|
392
|
+
return {
|
|
393
|
+
async get(identity) {
|
|
394
|
+
return rows.get(identityKey(identity)) ?? null;
|
|
395
|
+
},
|
|
396
|
+
async insert(claim) {
|
|
397
|
+
const key = identityKey(claim.identity);
|
|
398
|
+
if (rows.has(key)) return false;
|
|
399
|
+
rows.set(key, claim);
|
|
400
|
+
return true;
|
|
401
|
+
},
|
|
402
|
+
async swap(identity, expectedLeaseToken, next) {
|
|
403
|
+
if (!ownedInFlight(identity, expectedLeaseToken)) return false;
|
|
404
|
+
rows.set(identityKey(identity), next);
|
|
405
|
+
return true;
|
|
406
|
+
},
|
|
407
|
+
async release(identity, expectedLeaseToken) {
|
|
408
|
+
if (!ownedInFlight(identity, expectedLeaseToken)) return false;
|
|
409
|
+
rows.delete(identityKey(identity));
|
|
410
|
+
return true;
|
|
411
|
+
},
|
|
412
|
+
async listLapsed(operation, now, limit) {
|
|
413
|
+
return [...rows.values()].filter((c) => c.identity.operation === operation && c.state === "in_flight" && c.leaseExpiresAt <= now).sort((a, b) => a.leaseExpiresAt.getTime() - b.leaseExpiresAt.getTime()).slice(0, limit);
|
|
414
|
+
},
|
|
415
|
+
claims: () => [...rows.values()]
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
//#endregion
|
|
419
|
+
export { DEFAULT_LEASE_MS, IdempotencyError, IdempotentReplayError, MAX_FINGERPRINT_DEPTH, completeClaim, createMemoryIdempotencyStore, decideClaim, fingerprintRequest, holdsLease, identityKey, isInFlight, isLeaseExpired, newLeaseToken, renewLease, runIdempotent, sameIdentity };
|
|
@@ -136,6 +136,12 @@ interface KeysetPaginationResultCore<TDoc> {
|
|
|
136
136
|
prev?: string | null;
|
|
137
137
|
/** Whether a previous page exists. Absent when the kit does not track it. */
|
|
138
138
|
hasPrev?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Cursor at this page's LAST row, whether or not more rows exist — feed it back as
|
|
141
|
+
* `after` to resume later (a change feed continues from its end, where `next` is null).
|
|
142
|
+
* `null` for an empty page. Absent when the kit does not mint it: treat as unknown.
|
|
143
|
+
*/
|
|
144
|
+
end?: string | null;
|
|
139
145
|
}
|
|
140
146
|
/**
|
|
141
147
|
* Keyset-paginated result envelope.
|
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import { QueryParserOptions } from "./types.mjs";
|
|
2
1
|
//#region src/query-parser/coerce.d.ts
|
|
3
2
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* URL value → typed scalar. A declared field type wins, and a value that does not fit it is
|
|
4
|
+
* refused. Without one, a conservative heuristic applies: never a leading-zero code (`007`), never a
|
|
5
|
+
* digit string long enough to be an id or to lose precision, and a date only for a RANGE operand —
|
|
6
|
+
* `?code=2026-01-01` is a string far more often than a date.
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
/** Field types a caller (or a kit, from its schema) can declare for exact coercion. */
|
|
9
|
+
type QueryFieldType = 'string' | 'number' | 'boolean' | 'date';
|
|
10
|
+
/** What a URL value coerces to. */
|
|
11
|
+
type QueryScalar = string | number | boolean | Date | null;
|
|
12
|
+
interface CoerceOptions {
|
|
13
|
+
/** The field's declared type, when known. */
|
|
14
|
+
fieldType?: QueryFieldType | undefined;
|
|
15
|
+
/** True for a range operand (`gt`/`gte`/`lt`/`lte`/`between`) — enables date detection. */
|
|
16
|
+
range?: boolean;
|
|
17
|
+
/** The parameter, for the error message. */
|
|
18
|
+
param: string;
|
|
19
|
+
}
|
|
20
|
+
declare function coerceQueryValue(raw: string, options: CoerceOptions): QueryScalar;
|
|
14
21
|
//#endregion
|
|
15
|
-
export {
|
|
22
|
+
export { CoerceOptions, QueryFieldType, QueryScalar, coerceQueryValue };
|
|
@@ -1,80 +1,48 @@
|
|
|
1
1
|
import { ISO_DATE_PATTERN } from "../filter/coerce-dates.mjs";
|
|
2
|
+
import { QueryGrammarError } from "./errors.mjs";
|
|
2
3
|
//#region src/query-parser/coerce.ts
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* URL value → typed scalar. A declared field type wins, and a value that does not fit it is
|
|
6
|
+
* refused. Without one, a conservative heuristic applies: never a leading-zero code (`007`), never a
|
|
7
|
+
* digit string long enough to be an id or to lose precision, and a date only for a RANGE operand —
|
|
8
|
+
* `?code=2026-01-01` is a string far more often than a date.
|
|
8
9
|
*/
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
const FALSEY_STRINGS = /* @__PURE__ */ new Set([
|
|
16
|
-
"false",
|
|
17
|
-
"0",
|
|
18
|
-
"no",
|
|
19
|
-
"off"
|
|
20
|
-
]);
|
|
21
|
-
const ISO_DATE_RE = ISO_DATE_PATTERN;
|
|
22
|
-
/**
|
|
23
|
-
* Coerce a single URL value to its field-declared type, or to a best-guess
|
|
24
|
-
* scalar when no hint exists. Always returns `string`, `number`, `boolean`,
|
|
25
|
-
* `Date`, or `null` — never `undefined`.
|
|
26
|
-
*/
|
|
27
|
-
function coerceValue(rawValue, fieldType) {
|
|
28
|
-
if (rawValue === "null") return null;
|
|
10
|
+
const HEURISTIC_NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/;
|
|
11
|
+
const MAX_HEURISTIC_NUMBER_LENGTH = 15;
|
|
12
|
+
const STRICT_NUMBER = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
13
|
+
function coerceQueryValue(raw, options) {
|
|
14
|
+
if (raw === "null") return null;
|
|
15
|
+
const { fieldType, param } = options;
|
|
29
16
|
switch (fieldType) {
|
|
30
|
-
case "
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
case "boolean":
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
17
|
+
case "string": return raw;
|
|
18
|
+
case "number":
|
|
19
|
+
if (!STRICT_NUMBER.test(raw)) throw new QueryGrammarError(param, `"${raw}" is not a number`);
|
|
20
|
+
return Number(raw);
|
|
21
|
+
case "boolean": {
|
|
22
|
+
const lower = raw.toLowerCase();
|
|
23
|
+
if (lower === "true" || lower === "1") return true;
|
|
24
|
+
if (lower === "false" || lower === "0") return false;
|
|
25
|
+
throw new QueryGrammarError(param, `"${raw}" is not a boolean (true/false)`);
|
|
38
26
|
}
|
|
39
|
-
case "
|
|
40
|
-
default: return heuristicCoerce(rawValue);
|
|
27
|
+
case "date": return toDate(raw) ?? fail(param, `"${raw}" is not an ISO-8601 date`);
|
|
41
28
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* - Integers that don't start with 0 (unless literal "0") → number.
|
|
49
|
-
* - Floats → number.
|
|
50
|
-
* - Anything else stays a string.
|
|
51
|
-
*
|
|
52
|
-
* We do NOT coerce arbitrary-looking numeric strings ("12345") because
|
|
53
|
-
* they're routinely SKUs, order IDs, or phone numbers. Callers needing
|
|
54
|
-
* reliable numeric coercion pass `fieldTypes: { age: 'number' }`.
|
|
55
|
-
*/
|
|
56
|
-
function heuristicCoerce(value) {
|
|
57
|
-
if (BOOLEAN_STRINGS.has(value) && value.length <= 5) {
|
|
58
|
-
if (value === "true" || value === "false") return value === "true";
|
|
29
|
+
if (raw === "true") return true;
|
|
30
|
+
if (raw === "false") return false;
|
|
31
|
+
if (options.range) {
|
|
32
|
+
const date = toDate(raw);
|
|
33
|
+
if (date) return date;
|
|
34
|
+
if (STRICT_NUMBER.test(raw) && Number.isFinite(Number(raw))) return Number(raw);
|
|
59
35
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
const d = new Date(value);
|
|
63
|
-
if (!Number.isNaN(d.getTime())) return d;
|
|
64
|
-
}
|
|
65
|
-
if (/^-?\d+\.\d+$/.test(value)) {
|
|
66
|
-
const n = Number(value);
|
|
67
|
-
if (Number.isFinite(n)) return n;
|
|
68
|
-
}
|
|
69
|
-
return value;
|
|
36
|
+
if (raw.length <= MAX_HEURISTIC_NUMBER_LENGTH && HEURISTIC_NUMBER.test(raw)) return Number(raw);
|
|
37
|
+
return raw;
|
|
70
38
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
39
|
+
function toDate(raw) {
|
|
40
|
+
if (!ISO_DATE_PATTERN.test(raw)) return void 0;
|
|
41
|
+
const date = new Date(raw);
|
|
42
|
+
return Number.isNaN(date.getTime()) ? void 0 : date;
|
|
43
|
+
}
|
|
44
|
+
function fail(param, reason) {
|
|
45
|
+
throw new QueryGrammarError(param, reason);
|
|
78
46
|
}
|
|
79
47
|
//#endregion
|
|
80
|
-
export {
|
|
48
|
+
export { coerceQueryValue };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { HttpError, ValidationErrorMeta } from "../errors/types.mjs";
|
|
2
|
+
//#region src/query-parser/errors.d.ts
|
|
3
|
+
/** The code every query-grammar refusal carries — stable across parsers and kits. */
|
|
4
|
+
declare const INVALID_QUERY_INPUT = "INVALID_QUERY_INPUT";
|
|
5
|
+
/**
|
|
6
|
+
* What kind of input was refused — so a lenient parser can relax one kind without the others:
|
|
7
|
+
* - `syntax`: not query grammar at all (a `$` key, an unknown operator, malformed nesting) —
|
|
8
|
+
* an operator-smuggling attempt looks like this, so it is never worth tolerating.
|
|
9
|
+
* - `policy`: well-formed but outside an allowlist (a field, operator or sort field).
|
|
10
|
+
* - `value`: a value that does not fit (a type, a bound, an unsafe regex, a length).
|
|
11
|
+
*/
|
|
12
|
+
type QueryErrorKind = 'syntax' | 'policy' | 'value';
|
|
13
|
+
/**
|
|
14
|
+
* A list query the grammar refuses (400). `validationErrors[0].path` names the offending
|
|
15
|
+
* parameter, so a client can attach the message to the filter control that produced it.
|
|
16
|
+
*/
|
|
17
|
+
declare class QueryGrammarError extends Error implements HttpError {
|
|
18
|
+
readonly status = 400;
|
|
19
|
+
readonly code = "INVALID_QUERY_INPUT";
|
|
20
|
+
readonly meta: {
|
|
21
|
+
readonly param: string;
|
|
22
|
+
readonly reason: string;
|
|
23
|
+
readonly kind: QueryErrorKind;
|
|
24
|
+
};
|
|
25
|
+
readonly validationErrors: ValidationErrorMeta[];
|
|
26
|
+
constructor(param: string, reason: string, kind?: QueryErrorKind);
|
|
27
|
+
}
|
|
28
|
+
declare function isQueryGrammarError(value: unknown): value is QueryGrammarError;
|
|
29
|
+
//#endregion
|
|
30
|
+
export { INVALID_QUERY_INPUT, QueryErrorKind, QueryGrammarError, isQueryGrammarError };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/query-parser/errors.ts
|
|
2
|
+
/** The code every query-grammar refusal carries — stable across parsers and kits. */
|
|
3
|
+
const INVALID_QUERY_INPUT = "INVALID_QUERY_INPUT";
|
|
4
|
+
/**
|
|
5
|
+
* A list query the grammar refuses (400). `validationErrors[0].path` names the offending
|
|
6
|
+
* parameter, so a client can attach the message to the filter control that produced it.
|
|
7
|
+
*/
|
|
8
|
+
var QueryGrammarError = class extends Error {
|
|
9
|
+
status = 400;
|
|
10
|
+
code = INVALID_QUERY_INPUT;
|
|
11
|
+
meta;
|
|
12
|
+
validationErrors;
|
|
13
|
+
constructor(param, reason, kind = "value") {
|
|
14
|
+
super(`Invalid query parameter "${param}": ${reason}`);
|
|
15
|
+
this.name = "QueryGrammarError";
|
|
16
|
+
this.meta = {
|
|
17
|
+
param,
|
|
18
|
+
reason,
|
|
19
|
+
kind
|
|
20
|
+
};
|
|
21
|
+
this.validationErrors = [{
|
|
22
|
+
validator: "query",
|
|
23
|
+
error: reason,
|
|
24
|
+
path: param
|
|
25
|
+
}];
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function isQueryGrammarError(value) {
|
|
29
|
+
return value instanceof Error && value.code === "INVALID_QUERY_INPUT";
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { INVALID_QUERY_INPUT, QueryGrammarError, isQueryGrammarError };
|