@lovrozagar/oat 0.7.0 → 0.7.3

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 (81) hide show
  1. package/README.md +327 -151
  2. package/dist/cli.js +32 -29
  3. package/dist/config/define-config.d.ts +31 -3
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.js +1 -0
  8. package/dist/reference/defects.d.ts +16 -0
  9. package/dist/reference/defects.d.ts.map +1 -1
  10. package/dist/reference/defects.js +16 -0
  11. package/dist/reference/defects.js.map +1 -1
  12. package/dist/reference/model.d.ts +9 -0
  13. package/dist/reference/model.d.ts.map +1 -1
  14. package/dist/reference/model.js +20 -0
  15. package/dist/reference/model.js.map +1 -1
  16. package/dist/reference/query.d.ts +5 -0
  17. package/dist/reference/query.d.ts.map +1 -1
  18. package/dist/reference/query.js +87 -21
  19. package/dist/reference/query.js.map +1 -1
  20. package/dist/reference/spec.d.ts.map +1 -1
  21. package/dist/reference/spec.js +47 -2
  22. package/dist/reference/spec.js.map +1 -1
  23. package/dist/reference/store-api.d.ts +5 -1
  24. package/dist/reference/store-api.d.ts.map +1 -1
  25. package/dist/reference/store-api.js +9 -1
  26. package/dist/reference/store-api.js.map +1 -1
  27. package/dist/reference/stores/memory.d.ts.map +1 -1
  28. package/dist/reference/stores/memory.js +11 -0
  29. package/dist/reference/stores/memory.js.map +1 -1
  30. package/dist/reference/stores/postgres.d.ts.map +1 -1
  31. package/dist/reference/stores/postgres.js +75 -19
  32. package/dist/reference/stores/postgres.js.map +1 -1
  33. package/dist/reference/stores/sqlite-driver.d.ts.map +1 -1
  34. package/dist/reference/stores/sqlite-driver.js +3 -0
  35. package/dist/reference/stores/sqlite-driver.js.map +1 -1
  36. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  37. package/dist/reference/stores/sqlite.js +78 -17
  38. package/dist/reference/stores/sqlite.js.map +1 -1
  39. package/dist/report/console.d.ts +7 -1
  40. package/dist/report/console.d.ts.map +1 -1
  41. package/dist/report/console.js +210 -3
  42. package/dist/report/console.js.map +1 -1
  43. package/dist/runtime/checks.d.ts +3 -0
  44. package/dist/runtime/checks.d.ts.map +1 -1
  45. package/dist/runtime/checks.js +1361 -19
  46. package/dist/runtime/checks.js.map +1 -1
  47. package/dist/runtime/client.d.ts +17 -1
  48. package/dist/runtime/client.d.ts.map +1 -1
  49. package/dist/runtime/client.js +21 -4
  50. package/dist/runtime/client.js.map +1 -1
  51. package/dist/runtime/progress.d.ts +21 -0
  52. package/dist/runtime/progress.d.ts.map +1 -1
  53. package/dist/runtime/progress.js +63 -21
  54. package/dist/runtime/progress.js.map +1 -1
  55. package/dist/runtime/query-capabilities.d.ts +24 -0
  56. package/dist/runtime/query-capabilities.d.ts.map +1 -0
  57. package/dist/runtime/query-capabilities.js +66 -0
  58. package/dist/runtime/query-capabilities.js.map +1 -0
  59. package/dist/runtime/run.d.ts +6 -2
  60. package/dist/runtime/run.d.ts.map +1 -1
  61. package/dist/runtime/run.js +105 -37
  62. package/dist/runtime/run.js.map +1 -1
  63. package/dist/runtime/runs.d.ts +23 -0
  64. package/dist/runtime/runs.d.ts.map +1 -0
  65. package/dist/runtime/runs.js +53 -0
  66. package/dist/runtime/runs.js.map +1 -0
  67. package/dist/spec/conventions.d.ts +10 -2
  68. package/dist/spec/conventions.d.ts.map +1 -1
  69. package/dist/spec/conventions.js +61 -7
  70. package/dist/spec/conventions.js.map +1 -1
  71. package/dist/spec/extensions.d.ts +11 -0
  72. package/dist/spec/extensions.d.ts.map +1 -1
  73. package/dist/spec/extensions.js +16 -4
  74. package/dist/spec/extensions.js.map +1 -1
  75. package/dist/spec/query-capabilities.d.ts +154 -0
  76. package/dist/spec/query-capabilities.d.ts.map +1 -0
  77. package/dist/spec/query-capabilities.js +734 -0
  78. package/dist/spec/query-capabilities.js.map +1 -0
  79. package/labs/annotated-openapi.yaml +21 -2
  80. package/labs/demo.mjs +4 -2
  81. package/package.json +1 -1
@@ -5,7 +5,8 @@
5
5
  * ground truth — they compare the API against itself, through independent projections, or against
6
6
  * the oracle of what oat just wrote. Check ids are stable: the conformance suite asserts on them.
7
7
  */
8
- import { filterTerm, selectTerm, sortTerm } from "../spec/conventions.js";
8
+ import { canWriteFilterOp, filterTerm, selectTerm, sortTerm, sortTermWithNulls } from "../spec/conventions.js";
9
+ import { FILTER_OPS, ORDERED_TYPES, fieldAllows, fieldAllowsNulls, isFilterOp, mergeQueryCapabilities, opsAreClosed, opsForField, } from "../spec/query-capabilities.js";
9
10
  import { requestContent } from "../spec/collection.js";
10
11
  import { pathTemplateMatches } from "../spec/load.js";
11
12
  import { encodeForOperation } from "./body.js";
@@ -86,6 +87,8 @@ function q(ctx, roles) {
86
87
  out[c.order] = roles.order;
87
88
  if (roles.search !== undefined && c.search !== undefined)
88
89
  out[c.search] = roles.search;
90
+ if (roles.searchMode !== undefined && c.searchMode !== undefined)
91
+ out[c.searchMode] = roles.searchMode;
89
92
  if (roles.filter !== undefined && c.filter !== undefined)
90
93
  out[c.filter] = roles.filter;
91
94
  return out;
@@ -110,6 +113,48 @@ function filterable(ctx) {
110
113
  * which fields are filterable — otherwise oat would be guessing at parameter names. */
111
114
  return c.grammar === "equality" && (ctx.query?.filterable.length ?? 0) > 0;
112
115
  }
116
+ function resolvedCaps(ctx) {
117
+ if (ctx.capabilities !== undefined)
118
+ return ctx.capabilities;
119
+ return mergeQueryCapabilities({
120
+ itemSchema: ctx.listOp.collection?.itemSchema ?? null,
121
+ tag: ctx.query === null
122
+ ? null
123
+ : {
124
+ filterable: ctx.query.filterable,
125
+ searchable: ctx.query.searchable,
126
+ selectable: ctx.query.selectable,
127
+ sortable: ctx.query.sortable,
128
+ source: ctx.query.source,
129
+ ...(ctx.query.filterableDeclared === undefined ? {} : { filterableDeclared: ctx.query.filterableDeclared }),
130
+ ...(ctx.query.sortableDeclared === undefined ? {} : { sortableDeclared: ctx.query.sortableDeclared }),
131
+ ...(ctx.query.searchableDeclared === undefined ? {} : { searchableDeclared: ctx.query.searchableDeclared }),
132
+ ...(ctx.query.selectableDeclared === undefined ? {} : { selectableDeclared: ctx.query.selectableDeclared }),
133
+ ...(ctx.query.filterFields === undefined ? {} : { filterFields: ctx.query.filterFields }),
134
+ ...(ctx.query.sortableFields === undefined ? {} : { sortableFields: ctx.query.sortableFields }),
135
+ ...(ctx.query.catalog === undefined ? {} : { catalog: ctx.query.catalog }),
136
+ ...(ctx.query.defaultOrder === undefined ? {} : { defaultOrder: ctx.query.defaultOrder }),
137
+ ...(ctx.query.stableTiebreak === undefined ? {} : { stableTiebreak: ctx.query.stableTiebreak }),
138
+ },
139
+ });
140
+ }
141
+ function filterableNames(ctx) {
142
+ return resolvedCaps(ctx).filterable.map((field) => field.field);
143
+ }
144
+ /** Filter field for identity predicates when it differs from the JSON identity. */
145
+ function filterIdentity(ctx) {
146
+ return resolvedCaps(ctx).identityFilter ?? ctx.identity;
147
+ }
148
+ function identityIsFilterable(ctx) {
149
+ const names = filterableNames(ctx);
150
+ return names.includes(filterIdentity(ctx)) || names.includes(ctx.identity);
151
+ }
152
+ function fieldByName(ctx, name) {
153
+ return resolvedCaps(ctx).filterable.find((field) => field.field === name);
154
+ }
155
+ function canUseOp(ctx, field, op) {
156
+ return canWriteFilterOp(conv(ctx), op) && fieldAllows(field, op, resolvedCaps(ctx));
157
+ }
113
158
  /**
114
159
  * Whether the endpoint can be walked forward at all, however it counts.
115
160
  *
@@ -315,15 +360,49 @@ function nullableField(ctx, candidates) {
315
360
  return null;
316
361
  }
317
362
  function firstFilterable(ctx, predicate) {
318
- const candidates = ctx.query?.filterable ?? [];
319
- return candidates.find(predicate) ?? null;
363
+ return filterableNames(ctx).find(predicate) ?? null;
364
+ }
365
+ function fieldsAllowing(ctx, op) {
366
+ if (!canWriteFilterOp(conv(ctx), op))
367
+ return [];
368
+ return resolvedCaps(ctx).filterable.filter((field) => fieldAllows(field, op, resolvedCaps(ctx)));
369
+ }
370
+ function distinctValues(records, field) {
371
+ const seen = new Set();
372
+ const out = [];
373
+ for (const record of records) {
374
+ const value = record[field];
375
+ if (value === null || value === undefined)
376
+ continue;
377
+ const key = String(value);
378
+ if (seen.has(key))
379
+ continue;
380
+ seen.add(key);
381
+ out.push(value);
382
+ }
383
+ return out;
384
+ }
385
+ function asTermValue(value) {
386
+ return typeof value === "number" ? value : String(value);
387
+ }
388
+ function setOf(records, identity) {
389
+ return new Set(ids(records, identity));
390
+ }
391
+ function sameSet(a, b) {
392
+ if (a.size !== b.size)
393
+ return false;
394
+ for (const item of a)
395
+ if (!b.has(item))
396
+ return false;
397
+ return true;
320
398
  }
321
399
  /**
322
400
  * Finds a record holding non-null values on two distinct, non-identity filterable fields — the
323
401
  * minimum needed to build a compound predicate that is guaranteed to match at least that record.
324
402
  */
325
403
  function twoFilterableFields(ctx) {
326
- const candidates = (ctx.query?.filterable ?? []).filter((f) => f !== ctx.identity);
404
+ const skip = new Set([ctx.identity, filterIdentity(ctx)]);
405
+ const candidates = filterableNames(ctx).filter((f) => !skip.has(f));
327
406
  for (const target of ctx.records) {
328
407
  const present = candidates.filter((f) => target[f] !== null && target[f] !== undefined);
329
408
  if (present[0] !== undefined && present[1] !== undefined) {
@@ -487,7 +566,7 @@ const unknownFilterRejected = {
487
566
  },
488
567
  };
489
568
  const equalityFilterSelectsOne = {
490
- applicable: (ctx) => filterable(ctx) && ctx.records.length > 0 && (ctx.query?.filterable.includes(ctx.identity) ?? false),
569
+ applicable: (ctx) => filterable(ctx) && ctx.records.length > 0 && identityIsFilterable(ctx),
491
570
  dependsOn: ["list.read-after-write"],
492
571
  id: "filter.equality-selects-exactly-one",
493
572
  needs: "a `filter` parameter that accepts the identity field",
@@ -496,7 +575,8 @@ const equalityFilterSelectsOne = {
496
575
  if (target === undefined)
497
576
  return;
498
577
  const id = String(target[ctx.identity]);
499
- const term = filterTerm(conv(ctx), ctx.identity, "eq", id);
578
+ const field = filterIdentity(ctx);
579
+ const term = filterTerm(conv(ctx), field, "eq", id);
500
580
  if (term === null)
501
581
  return;
502
582
  const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
@@ -508,16 +588,16 @@ const equalityFilterSelectsOne = {
508
588
  const got = ids(result.items, ctx.identity);
509
589
  if (got.length === 1 && got[0] === id)
510
590
  return;
511
- ctx.findings.backend(this.id, ctx.entityName, "equality filter on the identity does not select exactly one record", `filter=${ctx.identity}.eq.${id} returned ${got.length} records (${got.slice(0, 5).join(", ")})`, [result.exchange]);
591
+ ctx.findings.backend(this.id, ctx.entityName, "equality filter on the identity does not select exactly one record", `filter=${field}.eq.${id} returned ${got.length} records (${got.slice(0, 5).join(", ")})`, [result.exchange]);
512
592
  },
513
593
  };
514
594
  const zeroMatchFilter = {
515
- applicable: (ctx) => filterable(ctx) && (ctx.query?.filterable.includes(ctx.identity) ?? false),
595
+ applicable: (ctx) => filterable(ctx) && identityIsFilterable(ctx),
516
596
  dependsOn: ["list.read-after-write"],
517
597
  id: "filter.zero-match-returns-none",
518
598
  needs: "a `filter` parameter that accepts the identity field",
519
599
  async run(ctx) {
520
- const term = filterTerm(conv(ctx), ctx.identity, "eq", "oat-nonexistent-value-000");
600
+ const term = filterTerm(conv(ctx), filterIdentity(ctx), "eq", "oat-nonexistent-value-000");
521
601
  if (term === null)
522
602
  return;
523
603
  const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
@@ -530,7 +610,7 @@ const zeroMatchFilter = {
530
610
  },
531
611
  };
532
612
  const negationPartitions = {
533
- applicable: (ctx) => filterable(ctx) && ctx.records.length > 1 && (ctx.query?.filterable.includes(ctx.identity) ?? false),
613
+ applicable: (ctx) => filterable(ctx) && ctx.records.length > 1 && identityIsFilterable(ctx),
534
614
  dependsOn: [
535
615
  "list.read-after-write",
536
616
  /* Partitioning is asserted over field *values*. A backend that drops submitted fields
@@ -548,7 +628,7 @@ const negationPartitions = {
548
628
  return;
549
629
  /* Prefer a field with nulls in the cohort: partitioning on the identity can never expose
550
630
  * three-valued-logic bugs, because an identity is never null. */
551
- const field = nullableField(ctx, ctx.query?.filterable ?? []) ?? ctx.identity;
631
+ const field = nullableField(ctx, filterableNames(ctx)) ?? filterIdentity(ctx);
552
632
  const probe = ctx.records.map((r) => r[field]).find((v) => v !== null && v !== undefined);
553
633
  if (probe === undefined) {
554
634
  return ctx.findings.unresolved(this.id, ctx.entityName, `every record in the cohort has a null "${field}", leaving no value to negate`);
@@ -923,13 +1003,13 @@ const countMatchesWalk = {
923
1003
  id: "count.matches-filtered-set",
924
1004
  needs: "a total-count field and a `filter` parameter",
925
1005
  async run(ctx) {
926
- if (!(ctx.query?.filterable.includes(ctx.identity) ?? false))
1006
+ if (!identityIsFilterable(ctx))
927
1007
  return;
928
1008
  const target = ctx.records[0];
929
1009
  if (target === undefined)
930
1010
  return;
931
1011
  const id = String(target[ctx.identity]);
932
- const countTerm = filterTerm(conv(ctx), ctx.identity, "eq", id);
1012
+ const countTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
933
1013
  if (countTerm === null)
934
1014
  return;
935
1015
  const filtered = await list(ctx, { ...q(ctx, { limit: 100 }), ...countTerm });
@@ -1466,6 +1546,7 @@ const projectionsAgree = {
1466
1546
  * disagreement here is that same defect seen a second time. */
1467
1547
  "filter.equality-selects-exactly-one",
1468
1548
  "select.projection-honoured",
1549
+ "select.requested-fields-present",
1469
1550
  "sort.order-is-applied",
1470
1551
  ],
1471
1552
  id: "consistency.projections-agree",
@@ -2445,21 +2526,25 @@ const crossTenantItemRead = {
2445
2526
  },
2446
2527
  };
2447
2528
  const crossTenantFilterBypass = {
2448
- applicable: (ctx) => ctx.altAuth !== undefined && ctx.altScope !== undefined && filterable(ctx) && ctx.records.length > 0,
2529
+ applicable: (ctx) => ctx.altAuth !== undefined &&
2530
+ ctx.altScope !== undefined &&
2531
+ filterable(ctx) &&
2532
+ ctx.records.length > 0 &&
2533
+ tenantBoundary(ctx.listOp),
2449
2534
  /* The probe filters, so anything that stops a filter from selecting over the whole collection
2450
2535
  * hides the very record whose visibility is in question — a leak that cannot be observed is
2451
2536
  * not a leak that can be reported. */
2452
2537
  dependsOn: ["query.filter-selects-from-whole-set"],
2453
2538
  id: "tenant.filter-does-not-bypass-scope",
2454
- needs: "a second principal and a `filter` parameter",
2539
+ needs: "a second principal, a filter, and a tenant tagged or inferred from the path",
2455
2540
  async run(ctx) {
2456
2541
  const target = ctx.records[0];
2457
2542
  if (target === undefined || ctx.altAuth === undefined || ctx.altScope === undefined)
2458
2543
  return;
2459
- if (!(ctx.query?.filterable.includes(ctx.identity) ?? false))
2544
+ if (!identityIsFilterable(ctx))
2460
2545
  return;
2461
2546
  const id = String(target[ctx.identity]);
2462
- const tenantTerm = filterTerm(conv(ctx), ctx.identity, "eq", id);
2547
+ const tenantTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
2463
2548
  if (tenantTerm === null)
2464
2549
  return;
2465
2550
  const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...tenantTerm }, ctx.altAuth, ctx.altScope);
@@ -2467,9 +2552,28 @@ const crossTenantFilterBypass = {
2467
2552
  return;
2468
2553
  if (!ids(result.items, ctx.identity).includes(id))
2469
2554
  return;
2470
- ctx.findings.security(this.id, ctx.entityName, "a filter reaches records outside the caller's tenant", `a principal in another tenant filtered on ${ctx.identity}.eq.${id} and received the ` +
2555
+ /* Same split as the item check. A public catalogue has no tenant at all — a 200 for
2556
+ * `id.eq.<someone else's public row>` is the contract, not a leak. */
2557
+ const declared = ctx.listOp.tenantSource === "tag";
2558
+ const detail = `a principal in another tenant filtered on ${ctx.identity}.eq.${id} and received the ` +
2471
2559
  "record. The tenant predicate is applied to the base listing but not re-applied to " +
2472
- "filter matches, so any caller who can guess an id can read it.", [result.exchange]);
2560
+ "filter matches, so any caller who can guess an id can read it.";
2561
+ if (declared) {
2562
+ ctx.findings.security(this.id, ctx.entityName, "a filter reaches records outside the caller's tenant", `${detail} The list declares x-tenant: "${ctx.listOp.tenantParam}", so this crosses ` +
2563
+ "a boundary the document states exists.", [result.exchange]);
2564
+ return;
2565
+ }
2566
+ ctx.findings.report({
2567
+ check: this.id,
2568
+ detail: `${detail} oat inferred tenant scoping from the "${ctx.listOp.tenantParam}" path ` +
2569
+ "parameter; the document does not state it. A public catalogue has no tenant at all — " +
2570
+ "no x-tenant and no tenant-named path parameter — and this check then does not apply. " +
2571
+ "If the resource is not shared, declare x-tenant so the same 200 is SECURITY.",
2572
+ entity: ctx.entityName,
2573
+ evidence: [result.exchange],
2574
+ summary: "a filter crosses an inferred tenant boundary; the document does not say whether that is intended",
2575
+ verdict: "AMBIGUITY",
2576
+ });
2473
2577
  },
2474
2578
  };
2475
2579
  function sameTenantScope(a, b) {
@@ -2779,6 +2883,15 @@ const orderChangesResult = {
2779
2883
  "backend ignores silently gives every caller arbitrary ordering.", [ascending.exchange]);
2780
2884
  },
2781
2885
  };
2886
+ function numericLexicalDisagrees(ctx, field) {
2887
+ const values = ctx.records.map((row) => row[field]).filter((v) => typeof v === "number");
2888
+ if (values.length < 3)
2889
+ return false;
2890
+ const unique = [...new Set(values)];
2891
+ const asNumbers = [...unique].sort((a, b) => a - b);
2892
+ const asText = [...unique].sort((a, b) => String(a).localeCompare(String(b)));
2893
+ return asNumbers.join(",") !== asText.join(",");
2894
+ }
2782
2895
  function compareValues(a, b) {
2783
2896
  if (a === null || a === undefined)
2784
2897
  return -1;
@@ -3857,6 +3970,1199 @@ const documentedStatusHonoured = {
3857
3970
  ctx.findings.spec(this.id, ctx.entityName, "an operation returned a status the document does not declare", `${lines.join(". ")}. Clients generated from this document will not recognise the response.`, evidence);
3858
3971
  },
3859
3972
  };
3973
+ function pickFieldForOp(ctx, op, minDistinct = 2) {
3974
+ for (const field of fieldsAllowing(ctx, op)) {
3975
+ const values = distinctValues(ctx.records, field.field);
3976
+ if (values.length >= minDistinct)
3977
+ return { field, values };
3978
+ }
3979
+ return null;
3980
+ }
3981
+ function pickOrderedField(ctx) {
3982
+ const caps = resolvedCaps(ctx);
3983
+ for (const field of caps.filterable) {
3984
+ if (field.type !== undefined && !ORDERED_TYPES.has(field.type))
3985
+ continue;
3986
+ if (!canUseOp(ctx, field, "gt") || !canUseOp(ctx, field, "lt") || !canUseOp(ctx, field, "eq"))
3987
+ continue;
3988
+ const values = ctx.records.map((r) => r[field.field]).filter((v) => typeof v === "number");
3989
+ if (values.length < 3)
3990
+ continue;
3991
+ return { field, values };
3992
+ }
3993
+ return null;
3994
+ }
3995
+ const FOUNDATIONS = [
3996
+ "list.read-after-write",
3997
+ "create.persists-submitted-fields",
3998
+ "pagination.page-walk-covers-set",
3999
+ ];
4000
+ const filterInIsUnionOfEq = {
4001
+ applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 2) !== null,
4002
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
4003
+ id: "filter.in-is-union-of-eq",
4004
+ needs: "a field that allows `in` and at least two distinct values",
4005
+ async run(ctx) {
4006
+ const picked = pickFieldForOp(ctx, "in", 2);
4007
+ if (picked === null)
4008
+ return;
4009
+ const [a, b] = picked.values;
4010
+ if (a === undefined || b === undefined)
4011
+ return;
4012
+ const conventions = conv(ctx);
4013
+ const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(a), asTermValue(b)]);
4014
+ const eqA = filterTerm(conventions, picked.field.field, "eq", asTermValue(a));
4015
+ const eqB = filterTerm(conventions, picked.field.field, "eq", asTermValue(b));
4016
+ if (inTerm === null || eqA === null || eqB === null)
4017
+ return;
4018
+ const limit = ctx.query?.maxLimit ?? 100;
4019
+ const together = await collectSet(ctx, limit, inTerm);
4020
+ const onlyA = await collectSet(ctx, limit, eqA);
4021
+ const onlyB = await collectSet(ctx, limit, eqB);
4022
+ if (together === null || onlyA === null || onlyB === null) {
4023
+ return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for in() was rejected");
4024
+ }
4025
+ if (!together.complete || !onlyA.complete || !onlyB.complete) {
4026
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4027
+ }
4028
+ const expected = new Set([...ids(onlyA.items, ctx.identity), ...ids(onlyB.items, ctx.identity)]);
4029
+ const got = setOf(together.items, ctx.identity);
4030
+ if (sameSet(expected, got))
4031
+ return;
4032
+ ctx.findings.backend(this.id, ctx.entityName, "in() is not the union of the equalities it lists", `${picked.field.field}.in.(${String(a)},${String(b)}) returned ${got.size} record(s); ` +
4033
+ `eq on each value together match ${expected.size}.`, [together.last.exchange, onlyA.last.exchange, onlyB.last.exchange]);
4034
+ },
4035
+ };
4036
+ const filterNinComplementsIn = {
4037
+ applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 1) !== null && pickFieldForOp(ctx, "nin", 1) !== null,
4038
+ dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
4039
+ id: "filter.nin-complements-in",
4040
+ needs: "a field that allows both `in` and `nin`",
4041
+ async run(ctx) {
4042
+ const picked = pickFieldForOp(ctx, "in", 1);
4043
+ if (picked === null || !canUseOp(ctx, picked.field, "nin"))
4044
+ return;
4045
+ const value = picked.values[0];
4046
+ if (value === undefined)
4047
+ return;
4048
+ const conventions = conv(ctx);
4049
+ const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(value)]);
4050
+ const ninTerm = filterTerm(conventions, picked.field.field, "nin", [asTermValue(value)]);
4051
+ if (inTerm === null || ninTerm === null)
4052
+ return;
4053
+ const limit = ctx.query?.maxLimit ?? 100;
4054
+ const all = await collectSet(ctx, limit);
4055
+ const inside = await collectSet(ctx, limit, inTerm);
4056
+ const outside = await collectSet(ctx, limit, ninTerm);
4057
+ if (all === null || inside === null || outside === null) {
4058
+ return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for nin() was rejected");
4059
+ }
4060
+ if (!all.complete || !inside.complete || !outside.complete) {
4061
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4062
+ }
4063
+ const inIds = setOf(inside.items, ctx.identity);
4064
+ const ninIds = setOf(outside.items, ctx.identity);
4065
+ const overlap = [...inIds].filter((id) => ninIds.has(id));
4066
+ if (overlap.length > 0) {
4067
+ ctx.findings.backend(this.id, ctx.entityName, "in() and nin() both match the same record", `${overlap.length} record(s) appear in both ${picked.field.field}.in and .nin.`, [inside.last.exchange, outside.last.exchange]);
4068
+ return;
4069
+ }
4070
+ const expected = ids(all.items, ctx.identity).filter((id) => {
4071
+ const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
4072
+ return record !== undefined && record[picked.field.field] !== null && record[picked.field.field] !== undefined;
4073
+ });
4074
+ const union = new Set([...inIds, ...ninIds]);
4075
+ const missing = expected.filter((id) => !union.has(id));
4076
+ if (missing.length === 0)
4077
+ return;
4078
+ ctx.findings.backend(this.id, ctx.entityName, "in() and nin() do not cover the non-null set", `${missing.length} non-null record(s) match neither side.`, [all.last.exchange, inside.last.exchange, outside.last.exchange]);
4079
+ },
4080
+ };
4081
+ const filterGteIsGtOrEq = {
4082
+ applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "gte").length > 0,
4083
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4084
+ id: "filter.gte-is-gt-or-eq",
4085
+ needs: "an ordered field that allows `gte` and `gt`",
4086
+ async run(ctx) {
4087
+ const picked = pickOrderedField(ctx);
4088
+ if (picked === null || !canUseOp(ctx, picked.field, "gte"))
4089
+ return;
4090
+ const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
4091
+ if (threshold === undefined)
4092
+ return;
4093
+ await assertRangeUnion(ctx, this.id, picked.field.field, "gte", "gt", threshold);
4094
+ },
4095
+ };
4096
+ const filterLteIsLtOrEq = {
4097
+ applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "lte").length > 0,
4098
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4099
+ id: "filter.lte-is-lt-or-eq",
4100
+ needs: "an ordered field that allows `lte` and `lt`",
4101
+ async run(ctx) {
4102
+ const picked = pickOrderedField(ctx);
4103
+ if (picked === null || !canUseOp(ctx, picked.field, "lte"))
4104
+ return;
4105
+ const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
4106
+ if (threshold === undefined)
4107
+ return;
4108
+ await assertRangeUnion(ctx, this.id, picked.field.field, "lte", "lt", threshold);
4109
+ },
4110
+ };
4111
+ async function assertRangeUnion(ctx, check, field, closed, open, threshold) {
4112
+ const conventions = conv(ctx);
4113
+ const closedTerm = filterTerm(conventions, field, closed, threshold);
4114
+ const openTerm = filterTerm(conventions, field, open, threshold);
4115
+ const eqTerm = filterTerm(conventions, field, "eq", threshold);
4116
+ if (closedTerm === null || openTerm === null || eqTerm === null)
4117
+ return;
4118
+ const limit = ctx.query?.maxLimit ?? 100;
4119
+ const closedSet = await collectSet(ctx, limit, closedTerm);
4120
+ const openSet = await collectSet(ctx, limit, openTerm);
4121
+ const eqSet = await collectSet(ctx, limit, eqTerm);
4122
+ if (closedSet === null || openSet === null || eqSet === null) {
4123
+ return ctx.findings.unresolved(check, ctx.entityName, "one of the range listings was rejected");
4124
+ }
4125
+ if (!closedSet.complete || !openSet.complete || !eqSet.complete) {
4126
+ return ctx.findings.unresolved(check, ctx.entityName, "the collection is larger than the walk covers");
4127
+ }
4128
+ const expected = new Set([...ids(openSet.items, ctx.identity), ...ids(eqSet.items, ctx.identity)]);
4129
+ const got = setOf(closedSet.items, ctx.identity);
4130
+ if (sameSet(expected, got))
4131
+ return;
4132
+ ctx.findings.backend(check, ctx.entityName, `${closed} is not ${open} ∪ eq`, `${field}.${closed}.${threshold} returned ${got.size}; ${open} ∪ eq is ${expected.size}.`, [closedSet.last.exchange, openSet.last.exchange, eqSet.last.exchange]);
4133
+ }
4134
+ const filterOrderedTriplePartitions = {
4135
+ applicable: (ctx) => pickOrderedField(ctx) !== null,
4136
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4137
+ id: "filter.ordered-triple-partitions",
4138
+ needs: "an ordered field that allows `lt`, `eq`, and `gt`",
4139
+ async run(ctx) {
4140
+ const picked = pickOrderedField(ctx);
4141
+ if (picked === null)
4142
+ return;
4143
+ const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 2)];
4144
+ if (threshold === undefined)
4145
+ return;
4146
+ const conventions = conv(ctx);
4147
+ const lt = filterTerm(conventions, picked.field.field, "lt", threshold);
4148
+ const eq = filterTerm(conventions, picked.field.field, "eq", threshold);
4149
+ const gt = filterTerm(conventions, picked.field.field, "gt", threshold);
4150
+ if (lt === null || eq === null || gt === null)
4151
+ return;
4152
+ const limit = ctx.query?.maxLimit ?? 100;
4153
+ const all = await collectSet(ctx, limit);
4154
+ const lower = await collectSet(ctx, limit, lt);
4155
+ const equal = await collectSet(ctx, limit, eq);
4156
+ const higher = await collectSet(ctx, limit, gt);
4157
+ if (all === null || lower === null || equal === null || higher === null) {
4158
+ return ctx.findings.unresolved(this.id, ctx.entityName, "one of the triple listings was rejected");
4159
+ }
4160
+ if (!all.complete || !lower.complete || !equal.complete || !higher.complete) {
4161
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4162
+ }
4163
+ const sets = [setOf(lower.items, ctx.identity), setOf(equal.items, ctx.identity), setOf(higher.items, ctx.identity)];
4164
+ for (let i = 0; i < sets.length; i++) {
4165
+ for (let j = i + 1; j < sets.length; j++) {
4166
+ const left = sets[i];
4167
+ const right = sets[j];
4168
+ if (left === undefined || right === undefined)
4169
+ continue;
4170
+ const overlap = [...left].filter((id) => right.has(id));
4171
+ if (overlap.length > 0) {
4172
+ ctx.findings.backend(this.id, ctx.entityName, "lt / eq / gt are not pairwise disjoint", `${overlap.length} record(s) appear in more than one of ${picked.field.field} lt/eq/gt.`, [lower.last.exchange, equal.last.exchange, higher.last.exchange]);
4173
+ return;
4174
+ }
4175
+ }
4176
+ }
4177
+ const union = new Set([...(sets[0] ?? []), ...(sets[1] ?? []), ...(sets[2] ?? [])]);
4178
+ const expected = ids(all.items, ctx.identity).filter((id) => {
4179
+ const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
4180
+ return record !== undefined && typeof record[picked.field.field] === "number";
4181
+ });
4182
+ const missing = expected.filter((id) => !union.has(id));
4183
+ if (missing.length === 0)
4184
+ return;
4185
+ ctx.findings.backend(this.id, ctx.entityName, "lt ∪ eq ∪ gt does not cover the numeric set", `${missing.length} numeric record(s) match none of the three predicates.`, [all.last.exchange, lower.last.exchange, equal.last.exchange, higher.last.exchange]);
4186
+ },
4187
+ };
4188
+ const filterIlikeIsCaseInsensitive = {
4189
+ applicable: (ctx) => fieldsAllowing(ctx, "ilike").some((field) => canUseOp(ctx, field, "like")),
4190
+ dependsOn: [...FOUNDATIONS, "filter.like-metacharacters-escaped"],
4191
+ id: "filter.ilike-is-case-insensitive",
4192
+ needs: "a field that allows both `ilike` and `like`, and a string with a letter",
4193
+ async run(ctx) {
4194
+ const field = fieldsAllowing(ctx, "ilike").find((item) => canUseOp(ctx, item, "like"));
4195
+ if (field === undefined)
4196
+ return;
4197
+ const sample = ctx.records
4198
+ .map((row) => row[field.field])
4199
+ .find((value) => typeof value === "string" && /[A-Za-z]/.test(value));
4200
+ if (sample === undefined) {
4201
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no searchable string on the field has a letter");
4202
+ }
4203
+ const flipped = sample.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
4204
+ if (flipped === sample) {
4205
+ return ctx.findings.unresolved(this.id, ctx.entityName, "could not case-flip a letter in the sample");
4206
+ }
4207
+ const conventions = conv(ctx);
4208
+ const likeTerm = filterTerm(conventions, field.field, "like", flipped);
4209
+ const ilikeTerm = filterTerm(conventions, field.field, "ilike", flipped);
4210
+ if (likeTerm === null || ilikeTerm === null)
4211
+ return;
4212
+ const limit = ctx.query?.maxLimit ?? 100;
4213
+ const like = await list(ctx, { ...q(ctx, { limit }), ...likeTerm });
4214
+ const ilike = await list(ctx, { ...q(ctx, { limit }), ...ilikeTerm });
4215
+ if (like.exchange.status >= 400 || ilike.exchange.status >= 400) {
4216
+ return ctx.findings.unresolved(this.id, ctx.entityName, "like/ilike probe was rejected");
4217
+ }
4218
+ const likeHits = ids(like.items, ctx.identity);
4219
+ const ilikeHits = ids(ilike.items, ctx.identity);
4220
+ const original = ctx.records.filter((row) => row[field.field] === sample).map((row) => String(row[ctx.identity]));
4221
+ if (original.some((id) => ilikeHits.includes(id)) && !original.some((id) => likeHits.includes(id)))
4222
+ return;
4223
+ if (original.some((id) => ilikeHits.includes(id)) && original.some((id) => likeHits.includes(id))) {
4224
+ return ctx.findings.unresolved(this.id, ctx.entityName, "like also matched the case-flipped value — the store may already be case-insensitive");
4225
+ }
4226
+ ctx.findings.backend(this.id, ctx.entityName, "ilike is not case-insensitive relative to like", `ilike on ${JSON.stringify(flipped)} did not select the record whose ${field.field} is ${JSON.stringify(sample)}.`, [like.exchange, ilike.exchange]);
4227
+ },
4228
+ };
4229
+ function mixedNullField(ctx, field) {
4230
+ if (ctx.softDelete !== null && field === ctx.softDelete)
4231
+ return false;
4232
+ const values = ctx.records.map((row) => row[field]);
4233
+ const nulls = values.filter((value) => value === null || value === undefined).length;
4234
+ return nulls > 0 && nulls < values.length;
4235
+ }
4236
+ const filterIsNullSelectsNulls = {
4237
+ applicable: (ctx) => fieldsAllowing(ctx, "is").some((field) => mixedNullField(ctx, field.field)),
4238
+ dependsOn: [...FOUNDATIONS],
4239
+ id: "filter.is-null-selects-nulls",
4240
+ needs: "a field that allows `is` and a cohort that contains a null",
4241
+ async run(ctx) {
4242
+ const field = fieldsAllowing(ctx, "is").find((item) => mixedNullField(ctx, item.field));
4243
+ if (field === undefined)
4244
+ return;
4245
+ const conventions = conv(ctx);
4246
+ const nullTerm = filterTerm(conventions, field.field, "is", "null");
4247
+ const notNullTerm = filterTerm(conventions, field.field, "is", "notnull");
4248
+ if (nullTerm === null || notNullTerm === null)
4249
+ return;
4250
+ const limit = ctx.query?.maxLimit ?? 100;
4251
+ const all = await collectSet(ctx, limit);
4252
+ const nulls = await collectSet(ctx, limit, nullTerm);
4253
+ const present = await collectSet(ctx, limit, notNullTerm);
4254
+ if (all === null || nulls === null || present === null) {
4255
+ return ctx.findings.unresolved(this.id, ctx.entityName, "an is.null / is.notnull listing was rejected");
4256
+ }
4257
+ if (!all.complete || !nulls.complete || !present.complete) {
4258
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4259
+ }
4260
+ const expectedNulls = new Set(ctx.records
4261
+ .filter((row) => row[field.field] === null || row[field.field] === undefined)
4262
+ .map((row) => String(row[ctx.identity])));
4263
+ const gotNulls = setOf(nulls.items, ctx.identity);
4264
+ const overlap = [...gotNulls].filter((id) => setOf(present.items, ctx.identity).has(id));
4265
+ if (!sameSet(expectedNulls, gotNulls) || overlap.length > 0) {
4266
+ ctx.findings.backend(this.id, ctx.entityName, "is.null / is.notnull do not partition on nulls", `is.null returned ${gotNulls.size} record(s); ${expectedNulls.size} cohort rows are null. ` +
4267
+ (overlap.length > 0 ? `${overlap.length} appear on both sides.` : ""), [nulls.last.exchange, present.last.exchange]);
4268
+ }
4269
+ },
4270
+ };
4271
+ const filterContainsMembership = {
4272
+ applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => (field.type === "array" || fieldAllows(field, "contains", resolvedCaps(ctx))) &&
4273
+ canWriteFilterOp(conv(ctx), "contains") &&
4274
+ ctx.records.some((row) => Array.isArray(row[field.field]) && row[field.field].length > 0)),
4275
+ dependsOn: [...FOUNDATIONS],
4276
+ id: "filter.contains-membership",
4277
+ needs: "an array field that allows `contains` and a known element",
4278
+ async run(ctx) {
4279
+ const field = resolvedCaps(ctx).filterable.find((item) => canUseOp(ctx, item, "contains") &&
4280
+ ctx.records.some((row) => Array.isArray(row[item.field]) && row[item.field].length > 0));
4281
+ if (field === undefined)
4282
+ return;
4283
+ const sample = ctx.records.find((row) => Array.isArray(row[field.field]) && row[field.field].length > 0);
4284
+ const element = Array.isArray(sample?.[field.field]) ? sample[field.field][0] : undefined;
4285
+ if (element === undefined)
4286
+ return;
4287
+ const term = filterTerm(conv(ctx), field.field, "contains", asTermValue(element));
4288
+ if (term === null)
4289
+ return;
4290
+ const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, term);
4291
+ if (result === null) {
4292
+ return ctx.findings.unresolved(this.id, ctx.entityName, "contains probe was rejected");
4293
+ }
4294
+ if (!result.complete) {
4295
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4296
+ }
4297
+ const expected = new Set(ctx.records
4298
+ .filter((row) => Array.isArray(row[field.field]) &&
4299
+ row[field.field].some((item) => String(item) === String(element)))
4300
+ .map((row) => String(row[ctx.identity])));
4301
+ const got = setOf(result.items, ctx.identity);
4302
+ if (sameSet(expected, got))
4303
+ return;
4304
+ ctx.findings.backend(this.id, ctx.entityName, "contains does not select membership of the given element", `${field.field}.contains.${String(element)} returned ${got.size}; ${expected.size} records hold that element.`, [result.last.exchange]);
4305
+ },
4306
+ };
4307
+ const filterNestedAndOrDistributes = {
4308
+ applicable: (ctx) => conv(ctx).grammar === "postgrest" && filterableNames(ctx).length > 1,
4309
+ dependsOn: [...FOUNDATIONS, "filter.and-composes-as-intersection", "filter.or-composes-as-union"],
4310
+ id: "filter.nested-and-or-distributes",
4311
+ needs: "a postgrest-shaped grammar and two filterable fields",
4312
+ async run(ctx) {
4313
+ const picked = twoFilterableFields(ctx);
4314
+ if (picked === null) {
4315
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no record holds two non-null filterable fields");
4316
+ }
4317
+ const idField = filterIdentity(ctx);
4318
+ if (idField === picked.fieldA || idField === picked.fieldB || !identityIsFilterable(ctx)) {
4319
+ return ctx.findings.unresolved(this.id, ctx.entityName, "need a third identity filter term to nest and/or");
4320
+ }
4321
+ const conventions = conv(ctx);
4322
+ const fragA = filterFragment(conventions, picked.fieldA, String(picked.target[picked.fieldA]));
4323
+ const fragB = filterFragment(conventions, picked.fieldB, String(picked.target[picked.fieldB]));
4324
+ const fragC = filterFragment(conventions, idField, String(picked.target[ctx.identity]));
4325
+ if (fragA === null || fragB === null || fragC === null || conventions.filter === undefined)
4326
+ return;
4327
+ const nested = { [conventions.filter]: `and(${fragA},or(${fragB},${fragC}))` };
4328
+ const termA = filterTerm(conventions, picked.fieldA, "eq", String(picked.target[picked.fieldA]));
4329
+ const termB = filterTerm(conventions, picked.fieldB, "eq", String(picked.target[picked.fieldB]));
4330
+ const termC = filterTerm(conventions, idField, "eq", String(picked.target[ctx.identity]));
4331
+ if (termA === null || termB === null || termC === null)
4332
+ return;
4333
+ const limit = ctx.query?.maxLimit ?? 100;
4334
+ const onlyA = await collectSet(ctx, limit, termA);
4335
+ const onlyB = await collectSet(ctx, limit, termB);
4336
+ const onlyC = await collectSet(ctx, limit, termC);
4337
+ const combined = await collectSet(ctx, limit, nested);
4338
+ if (onlyA === null || onlyB === null || onlyC === null || combined === null) {
4339
+ return ctx.findings.unresolved(this.id, ctx.entityName, "a nested and/or listing was rejected");
4340
+ }
4341
+ if (!onlyA.complete || !onlyB.complete || !onlyC.complete || !combined.complete) {
4342
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4343
+ }
4344
+ const setA = setOf(onlyA.items, ctx.identity);
4345
+ const setB = setOf(onlyB.items, ctx.identity);
4346
+ const setC = setOf(onlyC.items, ctx.identity);
4347
+ const expected = new Set([...setA].filter((id) => setB.has(id) || setC.has(id)));
4348
+ const got = setOf(combined.items, ctx.identity);
4349
+ if (sameSet(expected, got))
4350
+ return;
4351
+ ctx.findings.backend(this.id, ctx.entityName, "and(A,or(B,C)) is not (A∩B) ∪ (A∩C)", `nested combinator returned ${got.size}; the distributed form is ${expected.size}.`, [combined.last.exchange, onlyA.last.exchange, onlyB.last.exchange, onlyC.last.exchange]);
4352
+ },
4353
+ };
4354
+ const filterAliasMatchesCanonical = {
4355
+ applicable: (ctx) => Object.keys(resolvedCaps(ctx).aliases).length > 0 && filterable(ctx),
4356
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
4357
+ id: "filter.alias-matches-canonical",
4358
+ needs: "a declared filter operator alias",
4359
+ async run(ctx) {
4360
+ const caps = resolvedCaps(ctx);
4361
+ const conventions = conv(ctx);
4362
+ const limit = ctx.query?.maxLimit ?? 100;
4363
+ for (const [alias, target] of Object.entries(caps.aliases)) {
4364
+ if (!isFilterOp(alias) || target === undefined)
4365
+ continue;
4366
+ const field = resolvedCaps(ctx).filterable.find((item) => fieldAllows(item, target, caps));
4367
+ if (field === undefined || !canWriteFilterOp(conventions, alias))
4368
+ continue;
4369
+ const sample = ctx.records.find((row) => row[field.field] != null);
4370
+ if (sample === undefined)
4371
+ continue;
4372
+ const value = asTermValue(sample[field.field]);
4373
+ const aliasTerm = filterTerm(conventions, field.field, alias, value);
4374
+ const targetTerm = filterTerm(conventions, field.field, target, value);
4375
+ if (aliasTerm === null || targetTerm === null)
4376
+ continue;
4377
+ const left = await collectSet(ctx, limit, aliasTerm);
4378
+ const right = await collectSet(ctx, limit, targetTerm);
4379
+ if (left === null || right === null) {
4380
+ return ctx.findings.unresolved(this.id, ctx.entityName, `alias ${alias} or ${target} was rejected`);
4381
+ }
4382
+ if (!sameSet(setOf(left.items, ctx.identity), setOf(right.items, ctx.identity))) {
4383
+ ctx.findings.backend(this.id, ctx.entityName, `alias ${alias} does not select the same set as ${target}`, `${field.field}.${alias} and ${field.field}.${target} disagreed on membership.`, [left.last.exchange, right.last.exchange]);
4384
+ return;
4385
+ }
4386
+ }
4387
+ },
4388
+ };
4389
+ function firstIllegalOp(ctx, field) {
4390
+ const caps = resolvedCaps(ctx);
4391
+ if (!opsAreClosed(field, caps))
4392
+ return undefined;
4393
+ const allowed = new Set(opsForField(field, caps));
4394
+ return FILTER_OPS.find((op) => !allowed.has(op) && canWriteFilterOp(conv(ctx), op));
4395
+ }
4396
+ const filterIllegalOpRejected = {
4397
+ applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
4398
+ dependsOn: ["list.read-after-write", "filter.unknown-field-rejected", "error.malformed-filter-not-5xx"],
4399
+ id: "filter.illegal-op-rejected",
4400
+ needs: "a field with a closed operator list",
4401
+ async run(ctx) {
4402
+ const field = resolvedCaps(ctx).filterable.find((item) => firstIllegalOp(ctx, item) !== undefined);
4403
+ const op = field === undefined ? undefined : firstIllegalOp(ctx, field);
4404
+ if (field === undefined || op === undefined)
4405
+ return;
4406
+ const sample = ctx.records.find((row) => row[field.field] != null);
4407
+ const term = filterTerm(conv(ctx), field.field, op, sample === undefined ? "oat-probe" : asTermValue(sample[field.field]));
4408
+ if (term === null)
4409
+ return;
4410
+ const baseline = await list(ctx, q(ctx, { limit: 100 }));
4411
+ const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
4412
+ if (result.exchange.status >= 500) {
4413
+ ctx.findings.backend(this.id, ctx.entityName, "an illegal filter operator produces a server error", `${field.field}.${op} returned ${result.exchange.status}; a rejected operator should be 4xx.`, [result.exchange]);
4414
+ return;
4415
+ }
4416
+ if (result.exchange.status >= 400)
4417
+ return;
4418
+ const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
4419
+ ctx.findings.backend(this.id, ctx.entityName, "an illegal filter operator is accepted", `${field.field}.${op} returned ${result.exchange.status}` +
4420
+ (same ? " with the unfiltered set, so the operator was ignored" : "") +
4421
+ ". A closed operator list is a contract: anything outside it must be rejected.", [baseline.exchange, result.exchange]);
4422
+ },
4423
+ };
4424
+ const filterEmptyIn = {
4425
+ applicable: (ctx) => resolvedCaps(ctx).emptyIn !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
4426
+ dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
4427
+ id: "filter.empty-in",
4428
+ needs: "`emptyIn` declared and a field that allows `in`",
4429
+ async run(ctx) {
4430
+ const picked = pickFieldForOp(ctx, "in", 1);
4431
+ const policy = resolvedCaps(ctx).emptyIn;
4432
+ if (picked === null || policy === undefined)
4433
+ return;
4434
+ const term = filterTerm(conv(ctx), picked.field.field, "in", []);
4435
+ if (term === null)
4436
+ return;
4437
+ const baseline = await list(ctx, q(ctx, { limit: 100 }));
4438
+ const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
4439
+ if (policy === "reject") {
4440
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4441
+ return;
4442
+ ctx.findings.backend(this.id, ctx.entityName, "empty in() was not rejected", `emptyIn=reject but ${picked.field.field}.in.() returned ${result.exchange.status}.`, [result.exchange]);
4443
+ return;
4444
+ }
4445
+ if (result.exchange.status >= 400) {
4446
+ return ctx.findings.unresolved(this.id, ctx.entityName, "empty in() was rejected under match-none");
4447
+ }
4448
+ if (result.items.length === 0)
4449
+ return;
4450
+ const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
4451
+ ctx.findings.backend(this.id, ctx.entityName, "empty in() did not match none", `emptyIn=match-none but ${picked.field.field}.in.() returned ${result.items.length} record(s)` +
4452
+ (same ? " — the unfiltered set, so the filter was ignored" : "") +
4453
+ ".", [baseline.exchange, result.exchange]);
4454
+ },
4455
+ };
4456
+ const filterInOverLimitRejected = {
4457
+ applicable: (ctx) => resolvedCaps(ctx).maxInValues !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
4458
+ dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
4459
+ id: "filter.in-over-limit-rejected",
4460
+ needs: "`maxInValues` declared and a field that allows `in`",
4461
+ async run(ctx) {
4462
+ const max = resolvedCaps(ctx).maxInValues;
4463
+ const picked = pickFieldForOp(ctx, "in", 1);
4464
+ if (max === undefined || picked === null)
4465
+ return;
4466
+ const members = Array.from({ length: max + 1 }, (_, i) => `oat-over-limit-${i}`);
4467
+ const term = filterTerm(conv(ctx), picked.field.field, "in", members);
4468
+ if (term === null)
4469
+ return;
4470
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
4471
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4472
+ return;
4473
+ ctx.findings.backend(this.id, ctx.entityName, "an over-limit in() list was accepted", `maxInValues=${max} but ${picked.field.field}.in with ${max + 1} members returned ${result.exchange.status}.`, [result.exchange]);
4474
+ },
4475
+ };
4476
+ const filterConditionCapRejected = {
4477
+ applicable: (ctx) => resolvedCaps(ctx).maxFilterConditions !== undefined &&
4478
+ conv(ctx).grammar === "postgrest" &&
4479
+ identityIsFilterable(ctx),
4480
+ dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
4481
+ id: "filter.condition-cap-rejected",
4482
+ needs: "`maxFilterConditions` declared and a grammar that can group eq terms",
4483
+ async run(ctx) {
4484
+ const max = resolvedCaps(ctx).maxFilterConditions;
4485
+ const parameter = conv(ctx).filter;
4486
+ if (max === undefined || parameter === undefined)
4487
+ return;
4488
+ const field = filterIdentity(ctx);
4489
+ const terms = Array.from({ length: max + 1 }, () => `${field}.eq.oat-cap`);
4490
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), [parameter]: `and(${terms.join(",")})` });
4491
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4492
+ return;
4493
+ ctx.findings.backend(this.id, ctx.entityName, "an over-limit filter expression was accepted", `maxFilterConditions=${max} but ${max + 1} eq terms returned ${result.exchange.status}.`, [result.exchange]);
4494
+ },
4495
+ };
4496
+ function probeValueForOp(ctx, field, op) {
4497
+ const sample = ctx.records.find((row) => row[field.field] != null);
4498
+ if (op === "is")
4499
+ return "null";
4500
+ if (op === "in" || op === "nin")
4501
+ return [sample === undefined ? "oat-probe" : asTermValue(sample[field.field])];
4502
+ if (op === "contains") {
4503
+ const arr = sample?.[field.field];
4504
+ if (Array.isArray(arr) && arr[0] !== undefined)
4505
+ return asTermValue(arr[0]);
4506
+ return "oat-probe";
4507
+ }
4508
+ if (sample === undefined)
4509
+ return "oat-probe";
4510
+ return asTermValue(sample[field.field]);
4511
+ }
4512
+ const declaredFilterableOpsAccepted = {
4513
+ applicable: (ctx) => ctx.query?.source === "tag" && resolvedCaps(ctx).filterable.some((field) => opsAreClosed(field, resolvedCaps(ctx))),
4514
+ dependsOn: ["list.read-after-write", "spec.declared-filterable-is-filterable", "error.malformed-filter-not-5xx"],
4515
+ id: "spec.declared-filterable-ops-accepted",
4516
+ needs: "a closed operator list on at least one declared field",
4517
+ async run(ctx) {
4518
+ const caps = resolvedCaps(ctx);
4519
+ const rejected = [];
4520
+ for (const field of caps.filterable) {
4521
+ if (!opsAreClosed(field, caps))
4522
+ continue;
4523
+ for (const op of opsForField(field, caps)) {
4524
+ if (!canWriteFilterOp(conv(ctx), op))
4525
+ continue;
4526
+ const term = filterTerm(conv(ctx), field.field, op, probeValueForOp(ctx, field, op));
4527
+ if (term === null)
4528
+ continue;
4529
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
4530
+ if (result.exchange.status >= 400 && result.exchange.status < 500) {
4531
+ rejected.push(`${field.field}.${op} (${result.exchange.status})`);
4532
+ }
4533
+ }
4534
+ }
4535
+ if (rejected.length === 0)
4536
+ return;
4537
+ ctx.findings.spec(this.id, ctx.entityName, "the document declares a filter operator the backend rejects", `declared ops that 4xx: ${rejected.slice(0, 8).join(", ")}.`, []);
4538
+ },
4539
+ };
4540
+ const declaredFilterableIllegalOpRejected = {
4541
+ applicable: (ctx) => ctx.query?.source === "tag" &&
4542
+ resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
4543
+ dependsOn: ["filter.illegal-op-rejected", "spec.declared-filterable-is-filterable"],
4544
+ id: "spec.declared-filterable-illegal-op-rejected",
4545
+ needs: "a closed operator list on a declared field",
4546
+ async run(ctx) {
4547
+ await filterIllegalOpRejected.run(ctx);
4548
+ },
4549
+ };
4550
+ const sortUnknownFieldRejected = {
4551
+ applicable: (ctx) => conv(ctx).order !== undefined,
4552
+ dependsOn: ["error.malformed-filter-not-5xx"],
4553
+ id: "sort.unknown-field-rejected",
4554
+ needs: "an order parameter",
4555
+ async run(ctx) {
4556
+ const result = await list(ctx, q(ctx, { limit: 5, order: sortTerm(conv(ctx), "oat_no_such_sort_xyz", "asc") }));
4557
+ if (result.exchange.status >= 500) {
4558
+ ctx.findings.backend(this.id, ctx.entityName, "unknown sort field produces a server error", `ordering on an undeclared field returned ${result.exchange.status}; a rejected input should be 4xx.`, [result.exchange]);
4559
+ return;
4560
+ }
4561
+ if (result.exchange.status >= 400)
4562
+ return;
4563
+ const baseline = await list(ctx, q(ctx, { limit: 5 }));
4564
+ const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
4565
+ ctx.findings.backend(this.id, ctx.entityName, "unknown sort field is silently ignored", `order on an undeclared field returned ${result.exchange.status}` +
4566
+ (same ? " with the default order, so the field was dropped" : "") +
4567
+ ".", [baseline.exchange, result.exchange]);
4568
+ },
4569
+ };
4570
+ const sortNumericOrderIsNumeric = {
4571
+ applicable: (ctx) => conv(ctx).order !== undefined &&
4572
+ resolvedCaps(ctx).sortable.some((field) => numericLexicalDisagrees(ctx, field.field)),
4573
+ dependsOn: ["sort.order-is-applied"],
4574
+ id: "sort.numeric-order-is-numeric",
4575
+ needs: "a numeric sortable field whose lexical order disagrees with numeric order",
4576
+ async run(ctx) {
4577
+ const field = resolvedCaps(ctx).sortable.find((item) => numericLexicalDisagrees(ctx, item.field));
4578
+ if (field === undefined)
4579
+ return;
4580
+ const result = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100, order: sortTerm(conv(ctx), field.field, "asc") }));
4581
+ if (result.exchange.status >= 400)
4582
+ return;
4583
+ const numbers = result.items.map((item) => item[field.field]).filter((v) => typeof v === "number");
4584
+ const sorted = [...numbers].sort((a, b) => a - b);
4585
+ if (numbers.join(",") === sorted.join(","))
4586
+ return;
4587
+ ctx.findings.backend(this.id, ctx.entityName, `"${field.field}" is sorted as text rather than as a number`, `order=${field.field}.asc returned ${numbers.slice(0, 8).join(", ")}.`, [result.exchange]);
4588
+ },
4589
+ };
4590
+ const sortNullsFirstLast = {
4591
+ applicable: (ctx) => conv(ctx).order !== undefined &&
4592
+ sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
4593
+ resolvedCaps(ctx).sortable.some((field) => (fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")) &&
4594
+ ctx.records.some((row) => row[field.field] === null || row[field.field] === undefined)),
4595
+ dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
4596
+ id: "sort.nulls-first-last",
4597
+ needs: "a declared nulls token, a dotted sort grammar, and a null in the cohort",
4598
+ async run(ctx) {
4599
+ const field = resolvedCaps(ctx).sortable.find((item) => (fieldAllowsNulls(item, resolvedCaps(ctx), "first") || fieldAllowsNulls(item, resolvedCaps(ctx), "last")) &&
4600
+ ctx.records.some((row) => row[item.field] === null || row[item.field] === undefined));
4601
+ if (field === undefined)
4602
+ return;
4603
+ const limit = ctx.query?.maxLimit ?? 100;
4604
+ if (fieldAllowsNulls(field, resolvedCaps(ctx), "first")) {
4605
+ const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "first");
4606
+ if (clause === null)
4607
+ return;
4608
+ const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
4609
+ if (result === null)
4610
+ return ctx.findings.unresolved(this.id, ctx.entityName, "nullsfirst listing was rejected");
4611
+ const first = result.items[0];
4612
+ if (first !== undefined && first[field.field] !== null && first[field.field] !== undefined) {
4613
+ ctx.findings.backend(this.id, ctx.entityName, "nullsfirst did not put nulls first", `order=${clause} started with ${JSON.stringify(first[field.field])}.`, [result.last.exchange]);
4614
+ return;
4615
+ }
4616
+ }
4617
+ if (fieldAllowsNulls(field, resolvedCaps(ctx), "last")) {
4618
+ const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "last");
4619
+ if (clause === null)
4620
+ return;
4621
+ const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
4622
+ if (result === null)
4623
+ return ctx.findings.unresolved(this.id, ctx.entityName, "nullslast listing was rejected");
4624
+ const last = result.items.at(-1);
4625
+ if (last !== undefined && last[field.field] !== null && last[field.field] !== undefined) {
4626
+ ctx.findings.backend(this.id, ctx.entityName, "nullslast did not put nulls last", `order=${clause} ended with ${JSON.stringify(last[field.field])}.`, [result.last.exchange]);
4627
+ }
4628
+ }
4629
+ },
4630
+ };
4631
+ function pickMultiKeyPair(ctx) {
4632
+ const fields = ctx.query?.sortable ?? [];
4633
+ for (const primary of fields) {
4634
+ for (const secondary of fields) {
4635
+ if (secondary === primary || secondary === ctx.identity)
4636
+ continue;
4637
+ const groups = new Map();
4638
+ for (const row of ctx.records) {
4639
+ const key = JSON.stringify(row[primary]);
4640
+ const group = groups.get(key) ?? [];
4641
+ group.push(row);
4642
+ groups.set(key, group);
4643
+ }
4644
+ const tied = [...groups.values()].find((group) => group.length >= 2 && new Set(group.map((row) => JSON.stringify(row[secondary]))).size > 1);
4645
+ if (tied !== undefined)
4646
+ return { primary, secondary, tied };
4647
+ }
4648
+ }
4649
+ return null;
4650
+ }
4651
+ const sortMultiKeyTiebreak = {
4652
+ applicable: (ctx) => {
4653
+ const max = resolvedCaps(ctx).sort?.maxKeys;
4654
+ return conv(ctx).order !== undefined && (max === undefined || max >= 2) && pickMultiKeyPair(ctx) !== null;
4655
+ },
4656
+ dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
4657
+ id: "sort.multi-key-tiebreak",
4658
+ needs: "two sortable fields and rows that tie on the first and differ on the second",
4659
+ async run(ctx) {
4660
+ const pair = pickMultiKeyPair(ctx);
4661
+ if (pair === null) {
4662
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no cohort tie on the first key differs on the second");
4663
+ }
4664
+ const { primary, secondary, tied } = pair;
4665
+ const conventions = conv(ctx);
4666
+ const order = `${sortTerm(conventions, primary, "asc")},${sortTerm(conventions, secondary, "asc")}`;
4667
+ const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
4668
+ if (result === null)
4669
+ return ctx.findings.unresolved(this.id, ctx.entityName, "multi-key order was rejected");
4670
+ const slice = result.items.filter((item) => JSON.stringify(item[primary]) === JSON.stringify(tied[0]?.[primary]));
4671
+ const seconds = slice.map((item) => item[secondary]);
4672
+ const sorted = [...seconds].sort(compareValues);
4673
+ if (JSON.stringify(seconds) === JSON.stringify(sorted))
4674
+ return;
4675
+ ctx.findings.backend(this.id, ctx.entityName, "the second sort key is not applied on ties", `order=${order} left ties on "${primary}" unordered by "${secondary}".`, [result.last.exchange]);
4676
+ },
4677
+ };
4678
+ const sortDefaultOrderApplied = {
4679
+ applicable: (ctx) => (resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder) !== undefined && ctx.records.length > 1,
4680
+ dependsOn: ["sort.order-is-applied", "pagination.page-walk-covers-set"],
4681
+ id: "sort.default-order-applied",
4682
+ needs: "a declared defaultOrder and a complete walk",
4683
+ async run(ctx) {
4684
+ const declared = resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder;
4685
+ if (declared === undefined)
4686
+ return;
4687
+ const limit = ctx.query?.maxLimit ?? 100;
4688
+ const implicit = await collectSet(ctx, limit);
4689
+ const explicit = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, declared);
4690
+ if (implicit === null || explicit === null) {
4691
+ return ctx.findings.unresolved(this.id, ctx.entityName, "default-order walk was rejected");
4692
+ }
4693
+ if (!implicit.complete || !explicit.complete) {
4694
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the walk is incomplete, so default order cannot be compared");
4695
+ }
4696
+ if (ids(implicit.items, ctx.identity).join(",") === ids(explicit.items, ctx.identity).join(","))
4697
+ return;
4698
+ ctx.findings.backend(this.id, ctx.entityName, "omitting order does not match defaultOrder", `defaultOrder=${declared} produced a different sequence than the implicit listing.`, [implicit.last.exchange, explicit.last.exchange]);
4699
+ },
4700
+ };
4701
+ const sortStableTiebreak = {
4702
+ applicable: (ctx) => (resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak) !== undefined,
4703
+ dependsOn: ["sort.order-is-applied"],
4704
+ id: "sort.stable-tiebreak",
4705
+ needs: "a declared stableTiebreak and a tie on the primary key",
4706
+ async run(ctx) {
4707
+ const tiebreak = resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak;
4708
+ const primary = ctx.query?.sortable.find((name) => name !== tiebreak) ?? ctx.query?.sortable[0];
4709
+ if (tiebreak === undefined || primary === undefined)
4710
+ return;
4711
+ const order = sortTerm(conv(ctx), primary, "asc");
4712
+ const first = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
4713
+ const second = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
4714
+ if (first === null || second === null)
4715
+ return ctx.findings.unresolved(this.id, ctx.entityName, "repeat sort was rejected");
4716
+ if (ids(first.items, ctx.identity).join(",") === ids(second.items, ctx.identity).join(","))
4717
+ return;
4718
+ ctx.findings.backend(this.id, ctx.entityName, "the same order is not deterministic across two walks", `order=${order} (stableTiebreak=${tiebreak}) returned two different sequences.`, [first.last.exchange, second.last.exchange]);
4719
+ },
4720
+ };
4721
+ const declaredSortableNullsAccepted = {
4722
+ applicable: (ctx) => conv(ctx).order !== undefined &&
4723
+ sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
4724
+ resolvedCaps(ctx).sortable.some((field) => fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")),
4725
+ dependsOn: ["sort.order-is-applied"],
4726
+ id: "spec.declared-sortable-nulls-accepted",
4727
+ needs: "a declared nulls token on a sortable field",
4728
+ async run(ctx) {
4729
+ const rejected = [];
4730
+ for (const field of resolvedCaps(ctx).sortable) {
4731
+ for (const token of ["first", "last"]) {
4732
+ if (!fieldAllowsNulls(field, resolvedCaps(ctx), token))
4733
+ continue;
4734
+ const clause = sortTermWithNulls(conv(ctx), field.field, "asc", token);
4735
+ if (clause === null)
4736
+ continue;
4737
+ const result = await list(ctx, q(ctx, { limit: 5, order: clause }));
4738
+ if (result.exchange.status >= 400 && result.exchange.status < 500) {
4739
+ rejected.push(`${clause} (${result.exchange.status})`);
4740
+ }
4741
+ }
4742
+ }
4743
+ if (rejected.length === 0)
4744
+ return;
4745
+ ctx.findings.spec(this.id, ctx.entityName, "the document declares a nulls sort token the backend rejects", `rejected: ${rejected.join(", ")}.`, []);
4746
+ },
4747
+ };
4748
+ const searchTokensAnd = {
4749
+ applicable: (ctx) => conv(ctx).search !== undefined && (ctx.query?.searchable.length ?? 0) > 0 && ctx.records.length > 2,
4750
+ dependsOn: ["search.q-narrows-result"],
4751
+ id: "search.tokens-and",
4752
+ needs: "searchable fields and two tokens that split the cohort",
4753
+ async run(ctx) {
4754
+ const field = ctx.query?.searchable[0];
4755
+ if (field === undefined)
4756
+ return;
4757
+ const tokens = [];
4758
+ for (const row of ctx.records) {
4759
+ const value = row[field];
4760
+ if (typeof value !== "string")
4761
+ continue;
4762
+ const word = value.split(/\s+/).find((part) => part.length >= 3);
4763
+ if (word !== undefined)
4764
+ tokens.push(word.toLowerCase());
4765
+ }
4766
+ const unique = [...new Set(tokens)];
4767
+ if (unique.length < 2) {
4768
+ return ctx.findings.unresolved(this.id, ctx.entityName, "could not find two discriminating search tokens");
4769
+ }
4770
+ const [a, b] = unique;
4771
+ if (a === undefined || b === undefined)
4772
+ return;
4773
+ const limit = ctx.query?.maxLimit ?? 100;
4774
+ const onlyA = await list(ctx, q(ctx, { limit, search: a }));
4775
+ const onlyB = await list(ctx, q(ctx, { limit, search: b }));
4776
+ const both = await list(ctx, q(ctx, { limit, search: `${a} ${b}` }));
4777
+ if (onlyA.exchange.status >= 400 || onlyB.exchange.status >= 400 || both.exchange.status >= 400) {
4778
+ return ctx.findings.unresolved(this.id, ctx.entityName, "a token search was rejected");
4779
+ }
4780
+ const setA = setOf(onlyA.items, ctx.identity);
4781
+ const setB = setOf(onlyB.items, ctx.identity);
4782
+ const expected = new Set([...setA].filter((id) => setB.has(id)));
4783
+ const got = setOf(both.items, ctx.identity);
4784
+ if (sameSet(expected, got) || sameSet(got, new Set([...setA, ...setB])))
4785
+ return;
4786
+ ctx.findings.backend(this.id, ctx.entityName, "two search tokens are not applied as AND", `q="${a} ${b}" returned ${got.size}; the intersection of each token is ${expected.size}.`, [onlyA.exchange, onlyB.exchange, both.exchange]);
4787
+ },
4788
+ };
4789
+ const searchCaseInsensitive = {
4790
+ applicable: (ctx) => conv(ctx).search !== undefined &&
4791
+ (ctx.query?.searchable.length ?? 0) > 0 &&
4792
+ ctx.records.some((row) => ctx.query?.searchable.some((field) => typeof row[field] === "string" && /[A-Za-z]/.test(row[field]))),
4793
+ dependsOn: ["search.q-narrows-result"],
4794
+ id: "search.case-insensitive",
4795
+ needs: "a searchable string with a letter",
4796
+ async run(ctx) {
4797
+ let sample;
4798
+ for (const field of ctx.query?.searchable ?? []) {
4799
+ const value = ctx.records
4800
+ .map((row) => row[field])
4801
+ .find((item) => typeof item === "string" && /[A-Za-z]/.test(item));
4802
+ if (value !== undefined) {
4803
+ sample = value;
4804
+ break;
4805
+ }
4806
+ }
4807
+ if (sample === undefined)
4808
+ return;
4809
+ const token = sample.split(/\s+/).find((part) => /[A-Za-z]/.test(part)) ?? sample;
4810
+ const flipped = token.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
4811
+ const limit = ctx.query?.maxLimit ?? 100;
4812
+ const original = await list(ctx, q(ctx, { limit, search: token }));
4813
+ const other = await list(ctx, q(ctx, { limit, search: flipped }));
4814
+ if (original.exchange.status >= 400 || other.exchange.status >= 400)
4815
+ return;
4816
+ const a = setOf(original.items, ctx.identity);
4817
+ const b = setOf(other.items, ctx.identity);
4818
+ if (sameSet(a, b))
4819
+ return;
4820
+ if (resolvedCaps(ctx).searchCase === "insensitive") {
4821
+ ctx.findings.backend(this.id, ctx.entityName, "search is not case-insensitive", `q=${JSON.stringify(token)} and q=${JSON.stringify(flipped)} selected different sets.`, [original.exchange, other.exchange]);
4822
+ return;
4823
+ }
4824
+ ctx.findings.unresolved(this.id, ctx.entityName, "case-flipped search selected a different set; declare searchCase: insensitive to treat that as a defect");
4825
+ },
4826
+ };
4827
+ const searchEmptyQ = {
4828
+ applicable: (ctx) => conv(ctx).search !== undefined && resolvedCaps(ctx).searchEmpty !== undefined,
4829
+ dependsOn: ["search.q-narrows-result"],
4830
+ id: "search.empty-q",
4831
+ needs: "`searchEmpty` declared",
4832
+ async run(ctx) {
4833
+ const policy = resolvedCaps(ctx).searchEmpty;
4834
+ if (policy === undefined)
4835
+ return;
4836
+ const limit = ctx.query?.maxLimit ?? 100;
4837
+ const baseline = await list(ctx, q(ctx, { limit }));
4838
+ const empty = await list(ctx, q(ctx, { limit, search: "" }));
4839
+ if (policy === "reject") {
4840
+ if (empty.exchange.status >= 400 && empty.exchange.status < 500)
4841
+ return;
4842
+ ctx.findings.backend(this.id, ctx.entityName, "empty q was not rejected", `searchEmpty=reject but q= returned ${empty.exchange.status}.`, [empty.exchange]);
4843
+ return;
4844
+ }
4845
+ if (empty.exchange.status >= 400) {
4846
+ return ctx.findings.unresolved(this.id, ctx.entityName, "empty q was rejected");
4847
+ }
4848
+ if (sameSet(setOf(empty.items, ctx.identity), setOf(baseline.items, ctx.identity)))
4849
+ return;
4850
+ ctx.findings.backend(this.id, ctx.entityName, "empty q did not match the unfiltered set", `searchEmpty=${policy} but q= returned ${empty.items.length} of ${baseline.items.length} records.`, [baseline.exchange, empty.exchange]);
4851
+ },
4852
+ };
4853
+ const searchUndeclaredFieldNotRequired = {
4854
+ applicable: (ctx) => {
4855
+ if (conv(ctx).search === undefined || (ctx.query?.searchable.length ?? 0) === 0)
4856
+ return false;
4857
+ const searchable = new Set(ctx.query?.searchable ?? []);
4858
+ return ctx.records.some((row) => Object.entries(row).some(([key, value]) => !searchable.has(key) && typeof value === "string" && value.length >= 4 && !searchableHasToken(ctx, value)));
4859
+ },
4860
+ dependsOn: ["search.q-narrows-result"],
4861
+ id: "search.undeclared-field-not-required",
4862
+ needs: "a searchable field and a non-searchable string",
4863
+ async run() {
4864
+ /* Extra recall is not a defect. This check exists so undeclared-field hits are not
4865
+ * reported as SEARCH_IGNORED by other checks. */
4866
+ },
4867
+ };
4868
+ function searchableHasToken(ctx, token) {
4869
+ const needle = token.toLowerCase();
4870
+ return ctx.records.some((row) => (ctx.query?.searchable ?? []).some((field) => String(row[field] ?? "")
4871
+ .toLowerCase()
4872
+ .includes(needle)));
4873
+ }
4874
+ const searchModeAccepted = {
4875
+ applicable: (ctx) => conv(ctx).searchMode !== undefined &&
4876
+ (resolvedCaps(ctx).searchModes?.length ?? 0) > 0 &&
4877
+ conv(ctx).search !== undefined,
4878
+ id: "search.mode-accepted",
4879
+ needs: "declared searchModes and a search-mode parameter",
4880
+ async run(ctx) {
4881
+ const rejected = [];
4882
+ for (const mode of resolvedCaps(ctx).searchModes ?? []) {
4883
+ const result = await list(ctx, q(ctx, { limit: 5, search: "oat", searchMode: mode }));
4884
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4885
+ rejected.push(`${mode} (${result.exchange.status})`);
4886
+ }
4887
+ if (rejected.length === 0)
4888
+ return;
4889
+ ctx.findings.spec(this.id, ctx.entityName, "a declared search mode is rejected", `rejected: ${rejected.join(", ")}.`, []);
4890
+ },
4891
+ };
4892
+ const searchModesDiffer = {
4893
+ applicable: (ctx) => conv(ctx).searchMode !== undefined &&
4894
+ (resolvedCaps(ctx).searchModes?.length ?? 0) >= 2 &&
4895
+ conv(ctx).search !== undefined,
4896
+ dependsOn: ["search.mode-accepted"],
4897
+ id: "search.modes-differ",
4898
+ needs: "at least two declared searchModes and a mode parameter",
4899
+ async run(ctx) {
4900
+ const modes = resolvedCaps(ctx).searchModes ?? [];
4901
+ const a = modes[0];
4902
+ const b = modes[1];
4903
+ if (a === undefined || b === undefined)
4904
+ return;
4905
+ const field = ctx.query?.searchable[0];
4906
+ const token = field === undefined
4907
+ ? "oat"
4908
+ : ctx.records
4909
+ .map((row) => row[field])
4910
+ .find((value) => typeof value === "string" && value.length > 2);
4911
+ if (token === undefined)
4912
+ return;
4913
+ const left = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: a }));
4914
+ const right = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: b }));
4915
+ if (left.exchange.status >= 400 || right.exchange.status >= 400)
4916
+ return;
4917
+ if (ids(left.items, ctx.identity).join(",") !== ids(right.items, ctx.identity).join(","))
4918
+ return;
4919
+ ctx.findings.unresolved(this.id, ctx.entityName, `modes ${a} and ${b} returned the same set; cannot prove they are distinct implementations`);
4920
+ },
4921
+ };
4922
+ const selectRequestedFieldsPresent = {
4923
+ applicable: (ctx) => conv(ctx).select !== undefined && (ctx.query?.selectable.length ?? 0) > 0,
4924
+ dependsOn: ["select.projection-honoured"],
4925
+ id: "select.requested-fields-present",
4926
+ needs: "a select parameter and at least one selectable field",
4927
+ async run(ctx) {
4928
+ const requested = [
4929
+ ctx.identity,
4930
+ ...(ctx.query?.selectable.filter((name) => name !== ctx.identity).slice(0, 2) ?? []),
4931
+ ];
4932
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
4933
+ if (projection === null)
4934
+ return;
4935
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
4936
+ if (result.exchange.status >= 400 || result.items[0] === undefined) {
4937
+ return ctx.findings.unresolved(this.id, ctx.entityName, "select probe was rejected or empty");
4938
+ }
4939
+ const missing = requested.filter((name) => !Object.hasOwn(result.items[0], name));
4940
+ if (missing.length === 0)
4941
+ return;
4942
+ if (missing.length === 1 &&
4943
+ missing[0] === ctx.identity &&
4944
+ !(ctx.query?.selectable.includes(ctx.identity) ?? false)) {
4945
+ return ctx.findings.unresolved(this.id, ctx.entityName, "identity was omitted from select and missing from items");
4946
+ }
4947
+ ctx.findings.backend(this.id, ctx.entityName, "a requested select field is missing from the projection", `select=${requested.join(",")} omitted ${missing.join(", ")}.`, [result.exchange]);
4948
+ },
4949
+ };
4950
+ const selectUnknownFieldRejected = {
4951
+ applicable: (ctx) => conv(ctx).select !== undefined && resolvedCaps(ctx).select?.unknown !== undefined,
4952
+ dependsOn: ["select.projection-honoured", "error.malformed-filter-not-5xx"],
4953
+ id: "select.unknown-field-rejected",
4954
+ needs: "`select.unknown` declared",
4955
+ async run(ctx) {
4956
+ const policy = resolvedCaps(ctx).select?.unknown;
4957
+ if (policy === undefined)
4958
+ return;
4959
+ const requested = [ctx.identity, "oat_no_such_select_xyz"];
4960
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
4961
+ if (projection === null)
4962
+ return;
4963
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
4964
+ if (policy === "reject") {
4965
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4966
+ return;
4967
+ ctx.findings.backend(this.id, ctx.entityName, "unknown select field was not rejected", `select.unknown=reject but the probe returned ${result.exchange.status}.`, [result.exchange]);
4968
+ return;
4969
+ }
4970
+ if (result.exchange.status >= 400) {
4971
+ return ctx.findings.unresolved(this.id, ctx.entityName, "unknown select field was rejected under ignore");
4972
+ }
4973
+ const extras = Object.keys(result.items[0] ?? {}).filter((key) => key === "oat_no_such_select_xyz");
4974
+ if (extras.length === 0)
4975
+ return;
4976
+ ctx.findings.backend(this.id, ctx.entityName, "unknown select field was honoured under ignore", "select.unknown=ignore should drop the undeclared name, not return it.", [result.exchange]);
4977
+ },
4978
+ };
4979
+ const selectNestedHonoured = {
4980
+ applicable: (ctx) => conv(ctx).select !== undefined &&
4981
+ resolvedCaps(ctx).select?.nested === true &&
4982
+ (resolvedCaps(ctx).select?.relations?.length ?? 0) > 0,
4983
+ dependsOn: ["select.projection-honoured"],
4984
+ id: "select.nested-honoured",
4985
+ needs: "select.nested and a named relation",
4986
+ async run(ctx) {
4987
+ const relation = resolvedCaps(ctx).select?.relations?.[0];
4988
+ if (relation === undefined || relation.fields[0] === undefined)
4989
+ return;
4990
+ const clause = `${relation.name}(${relation.fields[0]})`;
4991
+ const projection = selectTerm(conv(ctx), [ctx.identity, clause], ctx.entityName);
4992
+ if (projection === null)
4993
+ return;
4994
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
4995
+ if (result.exchange.status >= 400 || result.items[0] === undefined) {
4996
+ return ctx.findings.unresolved(this.id, ctx.entityName, "nested select was rejected or empty");
4997
+ }
4998
+ const nested = result.items[0][relation.name];
4999
+ if (nested === null || typeof nested !== "object") {
5000
+ ctx.findings.backend(this.id, ctx.entityName, "nested select did not return the relation as an object", `select=${clause} left "${relation.name}" as ${typeof nested}.`, [result.exchange]);
5001
+ return;
5002
+ }
5003
+ const keys = Object.keys(nested);
5004
+ if (keys.every((key) => key === relation.fields[0]))
5005
+ return;
5006
+ ctx.findings.backend(this.id, ctx.entityName, "nested select did not restrict the relation to the requested field", `select=${clause} returned ${keys.join(", ") || "(empty)"}.`, [result.exchange]);
5007
+ },
5008
+ };
5009
+ const querySortAndSelectCompose = {
5010
+ applicable: (ctx) => conv(ctx).order !== undefined &&
5011
+ conv(ctx).select !== undefined &&
5012
+ (ctx.query?.sortable.length ?? 0) > 0 &&
5013
+ (ctx.query?.selectable.length ?? 0) > 0 &&
5014
+ ctx.records.length > 1,
5015
+ dependsOn: ["sort.order-is-applied", "select.projection-honoured", "select.requested-fields-present"],
5016
+ id: "query.sort-and-select-compose",
5017
+ needs: "sortable and selectable fields",
5018
+ async run(ctx) {
5019
+ const field = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
5020
+ const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
5021
+ if (field === undefined)
5022
+ return;
5023
+ const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
5024
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
5025
+ if (projection === null)
5026
+ return;
5027
+ const result = await list(ctx, {
5028
+ ...q(ctx, { limit: 20, order: sortTerm(conv(ctx), field, "asc") }),
5029
+ ...projection,
5030
+ });
5031
+ if (result.exchange.status >= 400 || result.items.length < 2) {
5032
+ return ctx.findings.unresolved(this.id, ctx.entityName, "sort+select probe was rejected");
5033
+ }
5034
+ const extras = Object.keys(result.items[0] ?? {}).filter((key) => !requested.includes(key));
5035
+ const values = result.items.map((item) => item[field]);
5036
+ const sorted = [...values].sort(compareValues);
5037
+ if (extras.length === 0 && JSON.stringify(values) === JSON.stringify(sorted))
5038
+ return;
5039
+ ctx.findings.backend(this.id, ctx.entityName, "sort and select do not compose", extras.length > 0
5040
+ ? `select leaked ${extras.slice(0, 5).join(", ")}.`
5041
+ : `order=${field}.asc did not hold under the projection.`, [result.exchange]);
5042
+ },
5043
+ };
5044
+ const querySearchAndSelectCompose = {
5045
+ applicable: (ctx) => conv(ctx).search !== undefined &&
5046
+ conv(ctx).select !== undefined &&
5047
+ (ctx.query?.searchable.length ?? 0) > 0 &&
5048
+ (ctx.query?.selectable.length ?? 0) > 0,
5049
+ dependsOn: ["search.q-narrows-result", "select.projection-honoured"],
5050
+ id: "query.search-and-select-compose",
5051
+ needs: "searchable and selectable fields",
5052
+ async run(ctx) {
5053
+ const field = ctx.query?.searchable[0];
5054
+ if (field === undefined)
5055
+ return;
5056
+ const token = ctx.records
5057
+ .map((row) => row[field])
5058
+ .find((value) => typeof value === "string" && value.length > 2);
5059
+ if (token === undefined)
5060
+ return;
5061
+ const requested = [ctx.identity, field];
5062
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
5063
+ if (projection === null)
5064
+ return;
5065
+ const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
5066
+ const combined = await list(ctx, { ...q(ctx, { limit: 100, search: token }), ...projection });
5067
+ if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
5068
+ return ctx.findings.unresolved(this.id, ctx.entityName, "search+select probe was rejected");
5069
+ }
5070
+ if (combined.items.some((item) => item[ctx.identity] === undefined)) {
5071
+ return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
5072
+ }
5073
+ if (sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity)))
5074
+ return;
5075
+ ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a search returns", `q=${JSON.stringify(token)} alone matched ${searched.items.length}; with select it matched ${combined.items.length}.`, [searched.exchange, combined.exchange]);
5076
+ },
5077
+ };
5078
+ const querySearchAndSortCompose = {
5079
+ applicable: (ctx) => conv(ctx).search !== undefined &&
5080
+ conv(ctx).order !== undefined &&
5081
+ (ctx.query?.searchable.length ?? 0) > 0 &&
5082
+ (ctx.query?.sortable.length ?? 0) > 0,
5083
+ dependsOn: ["search.q-narrows-result", "sort.order-is-applied"],
5084
+ id: "query.search-and-sort-compose",
5085
+ needs: "searchable and sortable fields",
5086
+ async run(ctx) {
5087
+ const searchField = ctx.query?.searchable[0];
5088
+ const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
5089
+ if (searchField === undefined || sortField === undefined)
5090
+ return;
5091
+ const token = ctx.records
5092
+ .map((row) => row[searchField])
5093
+ .find((value) => typeof value === "string" && value.length > 2);
5094
+ if (token === undefined)
5095
+ return;
5096
+ const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
5097
+ const combined = await list(ctx, q(ctx, { limit: 100, search: token, order: sortTerm(conv(ctx), sortField, "asc") }));
5098
+ if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
5099
+ return ctx.findings.unresolved(this.id, ctx.entityName, "search+sort probe was rejected");
5100
+ }
5101
+ if (!sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity))) {
5102
+ ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a search returns", `q=${JSON.stringify(token)} membership changed when order=${sortField} was added.`, [searched.exchange, combined.exchange]);
5103
+ return;
5104
+ }
5105
+ const values = combined.items.map((item) => item[sortField]);
5106
+ const sorted = [...values].sort(compareValues);
5107
+ if (JSON.stringify(values) === JSON.stringify(sorted))
5108
+ return;
5109
+ ctx.findings.backend(this.id, ctx.entityName, "search results are not ordered", `q + order=${sortField}.asc did not keep ${sortField} ascending.`, [combined.exchange]);
5110
+ },
5111
+ };
5112
+ const queryFilterSearchSortSelectCompose = {
5113
+ applicable: (ctx) => filterable(ctx) &&
5114
+ conv(ctx).search !== undefined &&
5115
+ conv(ctx).order !== undefined &&
5116
+ conv(ctx).select !== undefined &&
5117
+ (ctx.query?.searchable.length ?? 0) > 0 &&
5118
+ (ctx.query?.sortable.length ?? 0) > 0 &&
5119
+ (ctx.query?.selectable.length ?? 0) > 0 &&
5120
+ ctx.records.length > 2,
5121
+ dependsOn: [
5122
+ "query.search-and-filter-compose",
5123
+ "query.axes-compose",
5124
+ "query.filter-and-select-compose",
5125
+ "query.sort-and-select-compose",
5126
+ "query.filter-sort-select-compose",
5127
+ "query.filter-search-sort-compose",
5128
+ "query.filter-search-select-compose",
5129
+ "pagination.page-walk-covers-set",
5130
+ ],
5131
+ id: "query.filter-search-sort-select-compose",
5132
+ needs: "all four list axes declared and at least three records",
5133
+ async run(ctx) {
5134
+ const picked = overlappingFilterAndSearch(ctx);
5135
+ if (picked === null) {
5136
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no overlapping filter/search token");
5137
+ }
5138
+ const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
5139
+ const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
5140
+ if (sortField === undefined)
5141
+ return;
5142
+ const conventions = conv(ctx);
5143
+ const term = filterTerm(conventions, picked.field, "eq", String(picked.target[picked.field]));
5144
+ if (term === null)
5145
+ return;
5146
+ const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
5147
+ const projection = selectTerm(conventions, requested, ctx.entityName);
5148
+ if (projection === null)
5149
+ return;
5150
+ const base = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {
5151
+ ...term,
5152
+ ...(conventions.search === undefined ? {} : { [conventions.search]: picked.token }),
5153
+ });
5154
+ const combined = await collectSet(ctx, ctx.query?.maxLimit ?? 100, { ...term, ...projection, ...(conventions.search === undefined ? {} : { [conventions.search]: picked.token }) }, MAX_WALK_PAGES, sortTerm(conventions, sortField, "asc"));
5155
+ if (base === null || combined === null) {
5156
+ return ctx.findings.unresolved(this.id, ctx.entityName, "four-axis probe was rejected");
5157
+ }
5158
+ if (combined.items.some((item) => item[ctx.identity] === undefined)) {
5159
+ return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
5160
+ }
5161
+ if (sameSet(setOf(base.items, ctx.identity), setOf(combined.items, ctx.identity)))
5162
+ return;
5163
+ ctx.findings.backend(this.id, ctx.entityName, "filter ∩ search membership changes when sort and select join", `the four-axis listing returned ${combined.items.length}; filter+search alone returned ${base.items.length}.`, [base.last.exchange, combined.last.exchange]);
5164
+ },
5165
+ };
3860
5166
  export const CHECKS = [
3861
5167
  /* foundations — did the write land, is it visible, and do the paging primitives work at all.
3862
5168
  * Everything below assumes these hold, so they must be evaluated first for cascade
@@ -3874,23 +5180,47 @@ export const CHECKS = [
3874
5180
  * compare. Establishing paging first is what lets those failures be suppressed as cascades
3875
5181
  * rather than re-reported once per predicate. */
3876
5182
  orderChangesResult,
5183
+ sortNumericOrderIsNumeric,
5184
+ sortNullsFirstLast,
5185
+ sortMultiKeyTiebreak,
5186
+ sortDefaultOrderApplied,
5187
+ sortStableTiebreak,
3877
5188
  pageWalkCoversSet,
3878
5189
  cursorAgreesWithPage,
3879
5190
  /* query semantics */
3880
5191
  unknownFilterRejected,
3881
5192
  malformedFilterNot5xx,
5193
+ sortUnknownFieldRejected,
3882
5194
  equalityFilterSelectsOne,
3883
5195
  zeroMatchFilter,
3884
5196
  negationPartitions,
3885
5197
  filterAndComposesAsIntersection,
3886
5198
  filterOrComposesAsUnion,
5199
+ filterInIsUnionOfEq,
5200
+ filterNinComplementsIn,
5201
+ filterNestedAndOrDistributes,
3887
5202
  likeEscaping,
5203
+ filterIlikeIsCaseInsensitive,
5204
+ filterIsNullSelectsNulls,
5205
+ filterContainsMembership,
3888
5206
  sortReverseSymmetry,
3889
5207
  searchNarrowsResult,
5208
+ searchTokensAnd,
5209
+ searchCaseInsensitive,
5210
+ searchEmptyQ,
5211
+ searchUndeclaredFieldNotRequired,
5212
+ searchModeAccepted,
5213
+ searchModesDiffer,
3890
5214
  selectProjection,
5215
+ selectRequestedFieldsPresent,
5216
+ selectUnknownFieldRejected,
5217
+ selectNestedHonoured,
3891
5218
  countIsConsistentWithPage,
3892
5219
  countMatchesWalk,
3893
5220
  numericComparisonIsNumeric,
5221
+ filterGteIsGtOrEq,
5222
+ filterLteIsLtOrEq,
5223
+ filterOrderedTriplePartitions,
3894
5224
  /* write semantics */
3895
5225
  patchMinimality,
3896
5226
  immutableRejected,
@@ -3905,9 +5235,21 @@ export const CHECKS = [
3905
5235
  filterSortSelectCompose,
3906
5236
  filterSearchSortCompose,
3907
5237
  filterSearchSelectCompose,
5238
+ querySortAndSelectCompose,
5239
+ querySearchAndSelectCompose,
5240
+ querySearchAndSortCompose,
5241
+ queryFilterSearchSortSelectCompose,
3908
5242
  declaredFilterableWorks,
5243
+ declaredFilterableOpsAccepted,
5244
+ declaredFilterableIllegalOpRejected,
3909
5245
  declaredSortableWorks,
5246
+ declaredSortableNullsAccepted,
3910
5247
  declaredSelectableWorks,
5248
+ filterAliasMatchesCanonical,
5249
+ filterIllegalOpRejected,
5250
+ filterEmptyIn,
5251
+ filterInOverLimitRejected,
5252
+ filterConditionCapRejected,
3911
5253
  noLostUpdate,
3912
5254
  deleteMissingIs404,
3913
5255
  softDeleteHidden,