@classytic/repo-core 0.27.1 → 0.29.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 +1263 -1014
- 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/context.mjs +3 -1
- 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/keyset.mjs +0 -2
- package/dist/pagination/types.d.mts +18 -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/index.d.mts +2 -2
- package/dist/repository/index.mjs +2 -2
- package/dist/repository/resilience.d.mts +25 -20
- package/dist/repository/resilience.mjs +66 -2
- package/dist/repository/types.d.mts +105 -6
- package/dist/schema/field-rules.mjs +49 -6
- package/dist/schema/standard-schema.mjs +1 -1
- package/dist/testing/idempotency-conformance.d.mts +15 -0
- package/dist/testing/idempotency-conformance.mjs +315 -0
- package/dist/testing/index.d.mts +3 -1
- package/dist/testing/index.mjs +3 -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
|
@@ -9,24 +9,26 @@
|
|
|
9
9
|
* never drifts between call sites.
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* The default `shouldRetry`: retry a failure that may not have happened, never
|
|
13
|
+
* one that definitely did.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* This used to be `() => true`, with a comment conceding it was unsafe and
|
|
16
|
+
* asking callers to pass their own. That is a documented hazard rather than a
|
|
17
|
+
* fence — a policy set for network blips also re-ran the write on a duplicate
|
|
18
|
+
* key, a validation failure and a permission denial, three times, with backoff.
|
|
19
|
+
* For a non-idempotent write that is not a slow failure, it is a double write.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* Retried: replica-set failover, write conflicts, lock timeouts, socket errors
|
|
22
|
+
* — states where the outcome is genuinely unknown.
|
|
23
|
+
*
|
|
24
|
+
* NOT retried: anything the server answered deterministically. A 4xx-shaped
|
|
25
|
+
* `statusCode` is treated as final, since repo-core's own `HttpError` carries
|
|
26
|
+
* one and every kit maps validation/permission failures onto it.
|
|
27
|
+
*
|
|
28
|
+
* Pass `shouldRetry: () => true` to restore the old behaviour explicitly — the
|
|
29
|
+
* point is that it is now a choice someone made, not a default they inherited.
|
|
29
30
|
*/
|
|
31
|
+
declare function isTransientError(err: unknown): boolean;
|
|
30
32
|
interface RetryPolicy {
|
|
31
33
|
/** Max attempts (including the first try). Default 3 when a policy is present. */
|
|
32
34
|
maxAttempts?: number;
|
|
@@ -42,10 +44,13 @@ interface RetryPolicy {
|
|
|
42
44
|
*/
|
|
43
45
|
jitter?: boolean;
|
|
44
46
|
/**
|
|
45
|
-
* Decide whether a given error is transient.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* Decide whether a given error is transient.
|
|
48
|
+
*
|
|
49
|
+
* Defaults to {@link isTransientError}: retry a failure that MAY not have
|
|
50
|
+
* happened (failover, write conflict, socket error), never one that
|
|
51
|
+
* definitely did (duplicate key, validation, permission). Pass
|
|
52
|
+
* `() => true` to retry everything — allowed, but as a decision rather
|
|
53
|
+
* than an inheritance.
|
|
49
54
|
*/
|
|
50
55
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
|
51
56
|
}
|
|
@@ -66,4 +71,4 @@ declare function withRetry<T>(fn: () => Promise<T>, policy: RetryPolicy | undefi
|
|
|
66
71
|
*/
|
|
67
72
|
declare function throwIfAborted(signal: AbortSignal | undefined): void;
|
|
68
73
|
//#endregion
|
|
69
|
-
export { RetryPolicy, throwIfAborted, withRetry };
|
|
74
|
+
export { RetryPolicy, isTransientError, throwIfAborted, withRetry };
|
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
//#region src/repository/resilience.ts
|
|
2
2
|
/**
|
|
3
|
+
* Resilience primitives — the single retry/abort contract every kit and
|
|
4
|
+
* every chunked orchestrator (purge, batch imports, outbox relays) uses.
|
|
5
|
+
*
|
|
6
|
+
* One `RetryPolicy` shape across the contract: `QueryOptions.retryPolicy`,
|
|
7
|
+
* `TenantPurgeOptions.retry`, and any kit-internal retry loop all accept
|
|
8
|
+
* the same three knobs. One `withRetry` implementation so backoff math
|
|
9
|
+
* never drifts between call sites.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Retry policy for transient failures (network blips, write conflicts,
|
|
13
|
+
* busy-locks, connection resets).
|
|
14
|
+
*
|
|
15
|
+
* **Don't retry blindly.** Validation errors, schema errors, permission
|
|
16
|
+
* errors are NOT transient — retrying just delays the same failure.
|
|
17
|
+
* Mongo `WriteConflict`, SQLite `SQLITE_BUSY`, `ECONNRESET` ARE transient
|
|
18
|
+
* — backoff + retry recovers. Pass `shouldRetry` to narrow when you know
|
|
19
|
+
* your driver's error taxonomy:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* retryPolicy: {
|
|
23
|
+
* maxAttempts: 3, // default 3 when block present
|
|
24
|
+
* baseDelayMs: 100, // exponential: 100ms, 200ms, 400ms
|
|
25
|
+
* shouldRetry: (err) =>
|
|
26
|
+
* /WriteConflict|SQLITE_BUSY|ECONNRESET/i.test(String(err)),
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Error labels and names that mean "the operation may not have run — running
|
|
32
|
+
* it again is safe". Duck-typed so `repo-core` stays driver-free: MongoDB sets
|
|
33
|
+
* these labels, and other drivers surface the same idea under these names.
|
|
34
|
+
*/
|
|
35
|
+
const TRANSIENT_LABELS = ["RetryableWriteError", "TransientTransactionError"];
|
|
36
|
+
const TRANSIENT_PATTERN = /WriteConflict|LockTimeout|NotWritablePrimary|NotPrimary|PrimarySteppedDown|InterruptedDueToReplStateChange|ShutdownInProgress|HostUnreachable|HostNotFound|NetworkTimeout|SocketException|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|SQLITE_BUSY|SQLITE_LOCKED|MongoNetworkError/i;
|
|
37
|
+
/**
|
|
38
|
+
* The default `shouldRetry`: retry a failure that may not have happened, never
|
|
39
|
+
* one that definitely did.
|
|
40
|
+
*
|
|
41
|
+
* This used to be `() => true`, with a comment conceding it was unsafe and
|
|
42
|
+
* asking callers to pass their own. That is a documented hazard rather than a
|
|
43
|
+
* fence — a policy set for network blips also re-ran the write on a duplicate
|
|
44
|
+
* key, a validation failure and a permission denial, three times, with backoff.
|
|
45
|
+
* For a non-idempotent write that is not a slow failure, it is a double write.
|
|
46
|
+
*
|
|
47
|
+
* Retried: replica-set failover, write conflicts, lock timeouts, socket errors
|
|
48
|
+
* — states where the outcome is genuinely unknown.
|
|
49
|
+
*
|
|
50
|
+
* NOT retried: anything the server answered deterministically. A 4xx-shaped
|
|
51
|
+
* `statusCode` is treated as final, since repo-core's own `HttpError` carries
|
|
52
|
+
* one and every kit maps validation/permission failures onto it.
|
|
53
|
+
*
|
|
54
|
+
* Pass `shouldRetry: () => true` to restore the old behaviour explicitly — the
|
|
55
|
+
* point is that it is now a choice someone made, not a default they inherited.
|
|
56
|
+
*/
|
|
57
|
+
function isTransientError(err) {
|
|
58
|
+
if (err === null || typeof err !== "object") return false;
|
|
59
|
+
const e = err;
|
|
60
|
+
if (typeof e.hasErrorLabel === "function") for (const label of TRANSIENT_LABELS) try {
|
|
61
|
+
if (e.hasErrorLabel(label)) return true;
|
|
62
|
+
} catch {}
|
|
63
|
+
if (typeof e.statusCode === "number" && e.statusCode >= 400 && e.statusCode < 500) return false;
|
|
64
|
+
return TRANSIENT_PATTERN.test(`${String(e.name ?? "")} ${String(e.codeName ?? "")} ${String(e.code ?? "")} ${String(e.message ?? "")}`);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
3
67
|
* Abortable sleep. A plain `setTimeout` promise holds the process through an
|
|
4
68
|
* abort — a cancelled request would silently wait out its full backoff before
|
|
5
69
|
* noticing. Listener is removed on the timer path so repeated retries don't
|
|
@@ -31,7 +95,7 @@ async function withRetry(fn, policy, signal) {
|
|
|
31
95
|
if (!policy) return fn();
|
|
32
96
|
const maxAttempts = policy.maxAttempts ?? 3;
|
|
33
97
|
const baseDelayMs = policy.baseDelayMs ?? 100;
|
|
34
|
-
const shouldRetry = policy.shouldRetry ??
|
|
98
|
+
const shouldRetry = policy.shouldRetry ?? isTransientError;
|
|
35
99
|
let lastErr;
|
|
36
100
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
37
101
|
signal?.throwIfAborted();
|
|
@@ -59,4 +123,4 @@ function throwIfAborted(signal) {
|
|
|
59
123
|
signal?.throwIfAborted();
|
|
60
124
|
}
|
|
61
125
|
//#endregion
|
|
62
|
-
export { throwIfAborted, withRetry };
|
|
126
|
+
export { isTransientError, throwIfAborted, withRetry };
|
|
@@ -623,7 +623,37 @@ type AggMeasure = {
|
|
|
623
623
|
op: 'count';
|
|
624
624
|
field?: string;
|
|
625
625
|
where?: FilterInput;
|
|
626
|
-
} |
|
|
626
|
+
} |
|
|
627
|
+
/**
|
|
628
|
+
* Exact distinct count. **Bounded by group memory — see the ceiling below
|
|
629
|
+
* before using it on a high-cardinality field.**
|
|
630
|
+
*
|
|
631
|
+
* It is computed by accumulating the distinct values themselves and taking
|
|
632
|
+
* the size, so every distinct value in a group is held in that group's
|
|
633
|
+
* accumulator at once. Cheap for `countDistinct` over a status or a region;
|
|
634
|
+
* expensive over a user id, an email, or a session token, where the
|
|
635
|
+
* accumulator grows with the data rather than with the answer.
|
|
636
|
+
*
|
|
637
|
+
* MongoDB caps a single group accumulator at 100MB. Past that the pipeline
|
|
638
|
+
* fails — loudly, which is the right outcome, but it fails at the size the
|
|
639
|
+
* data reaches rather than at the size anyone planned for.
|
|
640
|
+
*
|
|
641
|
+
* Mitigations, in the order worth trying:
|
|
642
|
+
* 1. narrow the groups (`where`, a tighter `groupBy`) so each accumulator
|
|
643
|
+
* holds less;
|
|
644
|
+
* 2. pass `allowDiskUse` so the stage may spill instead of failing — it
|
|
645
|
+
* makes the query slow rather than impossible, and does NOT make every
|
|
646
|
+
* accumulator shape safe;
|
|
647
|
+
* 3. for genuinely unbounded cardinality, count distinct in its own
|
|
648
|
+
* request grouped by `(group keys, the distinct field)` and sum the
|
|
649
|
+
* rows — one pass more, no accumulator growth.
|
|
650
|
+
*
|
|
651
|
+
* There is deliberately no approximate mode: a distinct count that is
|
|
652
|
+
* quietly within a few percent is exactly the plausible-looking wrong number
|
|
653
|
+
* this contract exists to avoid. If an estimate is acceptable, the caller
|
|
654
|
+
* should ask for one explicitly rather than inherit it.
|
|
655
|
+
*/
|
|
656
|
+
{
|
|
627
657
|
op: 'countDistinct';
|
|
628
658
|
field: string;
|
|
629
659
|
where?: FilterInput;
|
|
@@ -814,7 +844,7 @@ type AggCacheOptions = CacheOptions;
|
|
|
814
844
|
* | --------------- | ------------------------------ | ------------------------ | ----------- |
|
|
815
845
|
* | `allowDiskUse` | `aggregate({ allowDiskUse })` | ignored (planner spills) | per-driver |
|
|
816
846
|
* | `maxTimeMs` | `aggregate({ maxTimeMS })` | ignored (sync driver) | per-driver |
|
|
817
|
-
* | `indexHint` | `
|
|
847
|
+
* | `indexHint` | `{ leadingKeys }` checked; else `hint` | ignored (planner-driven) | per-driver |
|
|
818
848
|
*
|
|
819
849
|
* **Hosts that need a hint to work** must pin the kit version that
|
|
820
850
|
* supports it via peer-deps. Falling back to "ignored" keeps mixed-
|
|
@@ -844,10 +874,11 @@ interface AggExecutionHints {
|
|
|
844
874
|
*/
|
|
845
875
|
maxTimeMs?: number;
|
|
846
876
|
/**
|
|
847
|
-
* Index hint
|
|
848
|
-
*
|
|
849
|
-
*
|
|
850
|
-
*
|
|
877
|
+
* Index hint. The PORTABLE form is `{ leadingKeys: string[] }` — an expectation that an index
|
|
878
|
+
* leading with those fields serves this read. It is never forced: mongokit checks a declared
|
|
879
|
+
* index matches (and throws a configuration error naming what is declared if none does); kits
|
|
880
|
+
* whose planner picks indexes itself (sqlitekit) ignore it. Any other shape is kit-specific and
|
|
881
|
+
* FORCED — mongokit sends a `{ field: 1 }` object or an index name as the driver `hint`.
|
|
851
882
|
*/
|
|
852
883
|
indexHint?: unknown;
|
|
853
884
|
}
|
|
@@ -1084,6 +1115,32 @@ interface AggRequest {
|
|
|
1084
1115
|
* each partition).
|
|
1085
1116
|
*/
|
|
1086
1117
|
topN?: AggTopN;
|
|
1118
|
+
/**
|
|
1119
|
+
* How `countDistinct` is computed.
|
|
1120
|
+
*
|
|
1121
|
+
* - **`'accumulator'`** (default) — collect the distinct values, take the
|
|
1122
|
+
* size. One pass, and the cheapest thing there is while a group's distinct
|
|
1123
|
+
* values fit in memory. They are held in that group's accumulator at once,
|
|
1124
|
+
* so the cost grows with the DATA, not with the answer: fine over a status
|
|
1125
|
+
* or a region, and a 100MB accumulator failure over a user id or an email.
|
|
1126
|
+
*
|
|
1127
|
+
* - **`'grouped'`** — pre-group by the distinct field so each distinct value
|
|
1128
|
+
* becomes a row, then count rows. One extra `$group`, and nothing
|
|
1129
|
+
* accumulates: cardinality stops being a memory question. Slower on small
|
|
1130
|
+
* groups, which is why it is not the default.
|
|
1131
|
+
*
|
|
1132
|
+
* Results are IDENTICAL either way, including the parts that are easy to get
|
|
1133
|
+
* wrong — a missing field is not a distinct value, an explicit `null` is, and
|
|
1134
|
+
* `where` excludes rows from the count without excluding them from the other
|
|
1135
|
+
* measures. `tests/integration/countdistinct-strategies.test.ts` asserts the
|
|
1136
|
+
* two strategies agree row for row rather than asserting expected numbers,
|
|
1137
|
+
* because a strategy that is merely self-consistent is the failure mode.
|
|
1138
|
+
*
|
|
1139
|
+
* `'grouped'` REFUSES a measure set it cannot re-aggregate (`avg`,
|
|
1140
|
+
* `percentile`, `stddev`, or a second `countDistinct`) instead of returning a
|
|
1141
|
+
* plausible wrong number — see the error for the alternatives.
|
|
1142
|
+
*/
|
|
1143
|
+
countDistinctStrategy?: 'accumulator' | 'grouped';
|
|
1087
1144
|
/**
|
|
1088
1145
|
* Driver-tunable performance knobs (allowDiskUse, maxTimeMs,
|
|
1089
1146
|
* indexHint). See `AggExecutionHints` for the per-kit support
|
|
@@ -1879,6 +1936,14 @@ interface ChangeEvent<TDoc = unknown> {
|
|
|
1879
1936
|
doc?: TDoc;
|
|
1880
1937
|
/** Commit timestamp as reported by the backend. */
|
|
1881
1938
|
timestamp: Date;
|
|
1939
|
+
/**
|
|
1940
|
+
* Resume token for THIS event (kit-specific opaque shape — Mongo's
|
|
1941
|
+
* change-document `_id`). Persist the last one you finished processing
|
|
1942
|
+
* and hand it back as `WatchOptions.resumeAfter` / `startAfter` to
|
|
1943
|
+
* continue from that point across restarts. Absent on backends whose
|
|
1944
|
+
* feed is not resumable.
|
|
1945
|
+
*/
|
|
1946
|
+
resumeToken?: unknown;
|
|
1882
1947
|
}
|
|
1883
1948
|
/** Options for `StandardRepo.watch()`. */
|
|
1884
1949
|
interface WatchOptions {
|
|
@@ -1890,6 +1955,40 @@ interface WatchOptions {
|
|
|
1890
1955
|
* for at-least-once consumption across restarts.
|
|
1891
1956
|
*/
|
|
1892
1957
|
resumeAfter?: unknown;
|
|
1958
|
+
/**
|
|
1959
|
+
* Like `resumeAfter`, but also resumes past an invalidate event (Mongo
|
|
1960
|
+
* `startAfter` — the token of the invalidate itself). Mutually exclusive
|
|
1961
|
+
* with `resumeAfter`; kits reject a call that sets both.
|
|
1962
|
+
*/
|
|
1963
|
+
startAfter?: unknown;
|
|
1964
|
+
/**
|
|
1965
|
+
* Ceiling on events held between the stream and a slower consumer.
|
|
1966
|
+
* Default 1024. `0` disables the bound; any other non-positive-integer
|
|
1967
|
+
* (negative, fractional, `Infinity`, `NaN`) is REFUSED rather than treated
|
|
1968
|
+
* as unbounded — silently removing the ceiling is the failure this option
|
|
1969
|
+
* exists to prevent.
|
|
1970
|
+
*
|
|
1971
|
+
* A change feed has no backpressure: the server pushes, and a kit bridging
|
|
1972
|
+
* it into an async iterator buffers whatever the consumer has not reached.
|
|
1973
|
+
* An unbounded buffer therefore turns a consumer that is merely SLOWER than
|
|
1974
|
+
* the write rate into unbounded memory growth — and the symptom is an OOM
|
|
1975
|
+
* far from the cause.
|
|
1976
|
+
*
|
|
1977
|
+
* Overflow ends the iterator with an error carrying `resumeToken` — the last
|
|
1978
|
+
* event the consumer ACKNOWLEDGED, meaning the last one it came back from.
|
|
1979
|
+
* Deliberately not the last DELIVERED event: resuming past one the consumer
|
|
1980
|
+
* was still processing drops it. The error also carries `deliveredToken` for
|
|
1981
|
+
* diagnostics; resuming from that reintroduces the loss.
|
|
1982
|
+
*
|
|
1983
|
+
* It never drops events either way. A change feed silently missing a delete
|
|
1984
|
+
* is the kind of wrong that surfaces as a reconciliation mystery months
|
|
1985
|
+
* later, so the asymmetry is chosen on purpose: redelivering costs a
|
|
1986
|
+
* duplicate, skipping costs a lost write.
|
|
1987
|
+
*
|
|
1988
|
+
* A change stream is an INGESTION mechanism, not a durable queue. Consumers
|
|
1989
|
+
* that must not miss events persist the resume token and stay idempotent.
|
|
1990
|
+
*/
|
|
1991
|
+
maxBufferedEvents?: number;
|
|
1893
1992
|
}
|
|
1894
1993
|
/**
|
|
1895
1994
|
* Options for `StandardRepo.cursor()` streaming reads. The open index
|
|
@@ -158,17 +158,60 @@ function mergeFieldRuleConstraints(schemas, schemaOptions) {
|
|
|
158
158
|
for (const [field, rule] of Object.entries(rules)) {
|
|
159
159
|
const prop = properties[field];
|
|
160
160
|
if (!prop || typeof prop !== "object") continue;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
161
|
+
for (const target of elementSchemas(prop)) {
|
|
162
|
+
if (rule.minLength != null && target["minLength"] == null) target["minLength"] = rule.minLength;
|
|
163
|
+
if (rule.maxLength != null && target["maxLength"] == null) target["maxLength"] = rule.maxLength;
|
|
164
|
+
if (rule.min != null && target["minimum"] == null) target["minimum"] = rule.min;
|
|
165
|
+
if (rule.max != null && target["maximum"] == null) target["maximum"] = rule.max;
|
|
166
|
+
if (rule.pattern != null && target["pattern"] == null) target["pattern"] = rule.pattern;
|
|
167
|
+
if (rule.enum != null && target["enum"] == null) target["enum"] = rule.enum;
|
|
168
|
+
}
|
|
167
169
|
if (rule.description != null && prop["description"] == null) prop["description"] = rule.description;
|
|
168
170
|
if (rule.nullable === true) applyNullable(prop);
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
}
|
|
174
|
+
/** Is this property typed as an array — `'array'` or a tuple that includes it? */
|
|
175
|
+
function isArrayTyped(prop) {
|
|
176
|
+
const type = prop["type"];
|
|
177
|
+
return type === "array" || Array.isArray(type) && type.includes("array");
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Where a VALUE constraint (`enum`, `pattern`, `minLength`, …) belongs.
|
|
181
|
+
*
|
|
182
|
+
* On a scalar property: the property. On an array property: every ELEMENT
|
|
183
|
+
* schema, never the array.
|
|
184
|
+
*
|
|
185
|
+
* Writing them onto the array is not merely redundant, it is wrong in two
|
|
186
|
+
* different directions depending on the keyword, and neither one throws:
|
|
187
|
+
*
|
|
188
|
+
* - `enum` on an array compares the WHOLE array to each member, which a list
|
|
189
|
+
* of strings can never equal — so every write is refused, including one that
|
|
190
|
+
* omits the field, and the error names a field the caller did not send.
|
|
191
|
+
* - `pattern`, `minLength`, `maxLength`, `minimum`, `maximum` are string /
|
|
192
|
+
* number keywords, and JSON Schema ignores them on an array — so the rule
|
|
193
|
+
* validates NOTHING while reading as enforced.
|
|
194
|
+
*
|
|
195
|
+
* `min`/`max` therefore bound each element's value, not the item count. That
|
|
196
|
+
* keeps one meaning per keyword; reinterpreting them as `minItems` on arrays
|
|
197
|
+
* only would make a rule's meaning depend on a type the author never wrote.
|
|
198
|
+
*
|
|
199
|
+
* A property with no `items` gets one, so the constraint is never dropped. A
|
|
200
|
+
* tuple-form `items` (an array of schemas) constrains every position.
|
|
201
|
+
*/
|
|
202
|
+
function elementSchemas(prop) {
|
|
203
|
+
if (Array.isArray(prop["anyOf"])) {
|
|
204
|
+
const branches = prop["anyOf"].filter((b) => b !== null && typeof b === "object" && isArrayTyped(b));
|
|
205
|
+
if (branches.length > 0) return branches.flatMap((b) => elementSchemas(b));
|
|
206
|
+
}
|
|
207
|
+
if (!isArrayTyped(prop)) return [prop];
|
|
208
|
+
const items = prop["items"];
|
|
209
|
+
if (Array.isArray(items)) return items.filter((s) => s !== null && typeof s === "object");
|
|
210
|
+
if (items && typeof items === "object") return [items];
|
|
211
|
+
const created = {};
|
|
212
|
+
prop["items"] = created;
|
|
213
|
+
return [created];
|
|
214
|
+
}
|
|
172
215
|
/**
|
|
173
216
|
* Widen a JSON Schema property to also accept `null`.
|
|
174
217
|
*
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { IdempotencyClaimStore } from "../idempotency/index.mjs";
|
|
2
|
+
//#region src/testing/idempotency-conformance.d.ts
|
|
3
|
+
interface IdempotencyConformanceHarness {
|
|
4
|
+
/** A store over an EMPTY backend, per scenario. */
|
|
5
|
+
createStore(): IdempotencyClaimStore | Promise<IdempotencyClaimStore>;
|
|
6
|
+
}
|
|
7
|
+
/** The contract's cases, runner-agnostic — each `run` throws on failure. */
|
|
8
|
+
declare function idempotencyStoreCases(harness: IdempotencyConformanceHarness): {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly run: () => Promise<void>;
|
|
11
|
+
}[];
|
|
12
|
+
/** Register every contract case as a vitest test against `harness.createStore()`. */
|
|
13
|
+
declare function runIdempotencyStoreConformance(harness: IdempotencyConformanceHarness): void;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { IdempotencyConformanceHarness, idempotencyStoreCases, runIdempotencyStoreConformance };
|