@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
@@ -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
- * Coerce a single URL value to its field-declared type, or to a best-guess
5
- * scalar when no hint exists. Always returns `string`, `number`, `boolean`,
6
- * `Date`, or `null` — never `undefined`.
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
- declare function coerceValue(rawValue: string, fieldType: QueryParserOptions['fieldTypes'] extends (infer T) ? T extends Record<string, infer V> ? V | undefined : undefined : undefined): unknown;
9
- /**
10
- * Split a comma-separated URL value into an array of coerced scalars.
11
- * Used by `in`/`nin`/`between` which accept `field[in]=a,b,c`.
12
- */
13
- declare function coerceList(rawValue: string, fieldType: Parameters<typeof coerceValue>[1]): unknown[];
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 { coerceList, coerceValue };
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
- * Scalar coercion. URLs are strings; filters compare against typed fields.
5
- * The parser uses `fieldTypes` hints when provided, otherwise a safe
6
- * heuristic that avoids the classic footguns (string SKUs becoming
7
- * numbers, numeric-looking strings becoming Dates).
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 BOOLEAN_STRINGS = /* @__PURE__ */ new Set([
10
- "true",
11
- "1",
12
- "yes",
13
- "on"
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 "number": {
31
- const n = Number(rawValue);
32
- return Number.isFinite(n) ? n : rawValue;
33
- }
34
- case "boolean": return BOOLEAN_STRINGS.has(rawValue.toLowerCase());
35
- case "date": {
36
- const d = new Date(rawValue);
37
- return Number.isNaN(d.getTime()) ? rawValue : d;
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 "string": return rawValue;
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
- * Heuristic used when no field-type hint applies. Deliberately conservative:
45
- *
46
- * - Pure boolean strings ("true", "false") → boolean.
47
- * - Unambiguous ISO-8601 dates → Date.
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 (FALSEY_STRINGS.has(value) && (value === "false" || value === "true")) return value === "true";
61
- if (ISO_DATE_RE.test(value)) {
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
- * Split a comma-separated URL value into an array of coerced scalars.
73
- * Used by `in`/`nin`/`between` which accept `field[in]=a,b,c`.
74
- */
75
- function coerceList(rawValue, fieldType) {
76
- if (rawValue.length === 0) return [];
77
- return rawValue.split(",").map((v) => coerceValue(v.trim(), fieldType));
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 { coerceList, coerceValue };
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 };