@lovrozagar/oat 0.6.2 → 0.7.2

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 (121) hide show
  1. package/README.md +443 -146
  2. package/dist/cli.js +35 -29
  3. package/dist/config/define-config.d.ts +125 -3
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js +6 -0
  6. package/dist/config/define-config.js.map +1 -1
  7. package/dist/index.d.ts +7 -2
  8. package/dist/index.js +4 -1
  9. package/dist/reference/defects.d.ts +16 -0
  10. package/dist/reference/defects.d.ts.map +1 -1
  11. package/dist/reference/defects.js +16 -0
  12. package/dist/reference/defects.js.map +1 -1
  13. package/dist/reference/model.d.ts +9 -0
  14. package/dist/reference/model.d.ts.map +1 -1
  15. package/dist/reference/model.js +20 -0
  16. package/dist/reference/model.js.map +1 -1
  17. package/dist/reference/query.d.ts +5 -0
  18. package/dist/reference/query.d.ts.map +1 -1
  19. package/dist/reference/query.js +87 -21
  20. package/dist/reference/query.js.map +1 -1
  21. package/dist/reference/spec.d.ts.map +1 -1
  22. package/dist/reference/spec.js +47 -2
  23. package/dist/reference/spec.js.map +1 -1
  24. package/dist/reference/store-api.d.ts +5 -1
  25. package/dist/reference/store-api.d.ts.map +1 -1
  26. package/dist/reference/store-api.js +9 -1
  27. package/dist/reference/store-api.js.map +1 -1
  28. package/dist/reference/stores/memory.d.ts.map +1 -1
  29. package/dist/reference/stores/memory.js +11 -0
  30. package/dist/reference/stores/memory.js.map +1 -1
  31. package/dist/reference/stores/postgres.d.ts.map +1 -1
  32. package/dist/reference/stores/postgres.js +75 -19
  33. package/dist/reference/stores/postgres.js.map +1 -1
  34. package/dist/reference/stores/sqlite-driver.d.ts.map +1 -1
  35. package/dist/reference/stores/sqlite-driver.js +3 -0
  36. package/dist/reference/stores/sqlite-driver.js.map +1 -1
  37. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  38. package/dist/reference/stores/sqlite.js +78 -17
  39. package/dist/reference/stores/sqlite.js.map +1 -1
  40. package/dist/report/console.d.ts +7 -1
  41. package/dist/report/console.d.ts.map +1 -1
  42. package/dist/report/console.js +210 -1
  43. package/dist/report/console.js.map +1 -1
  44. package/dist/report/render.d.ts.map +1 -1
  45. package/dist/report/render.js +4 -2
  46. package/dist/report/render.js.map +1 -1
  47. package/dist/runtime/auth.d.ts +22 -2
  48. package/dist/runtime/auth.d.ts.map +1 -1
  49. package/dist/runtime/auth.js +129 -16
  50. package/dist/runtime/auth.js.map +1 -1
  51. package/dist/runtime/body.d.ts.map +1 -1
  52. package/dist/runtime/body.js +6 -3
  53. package/dist/runtime/body.js.map +1 -1
  54. package/dist/runtime/checks.d.ts +9 -0
  55. package/dist/runtime/checks.d.ts.map +1 -1
  56. package/dist/runtime/checks.js +1414 -18
  57. package/dist/runtime/checks.js.map +1 -1
  58. package/dist/runtime/client.d.ts +23 -1
  59. package/dist/runtime/client.d.ts.map +1 -1
  60. package/dist/runtime/client.js +35 -5
  61. package/dist/runtime/client.js.map +1 -1
  62. package/dist/runtime/finding.d.ts +2 -0
  63. package/dist/runtime/finding.d.ts.map +1 -1
  64. package/dist/runtime/finding.js.map +1 -1
  65. package/dist/runtime/input.d.ts +7 -0
  66. package/dist/runtime/input.d.ts.map +1 -0
  67. package/dist/runtime/input.js +46 -0
  68. package/dist/runtime/input.js.map +1 -0
  69. package/dist/runtime/poll.d.ts +29 -0
  70. package/dist/runtime/poll.d.ts.map +1 -0
  71. package/dist/runtime/poll.js +59 -0
  72. package/dist/runtime/poll.js.map +1 -0
  73. package/dist/runtime/principals.d.ts +29 -0
  74. package/dist/runtime/principals.d.ts.map +1 -0
  75. package/dist/runtime/principals.js +97 -0
  76. package/dist/runtime/principals.js.map +1 -0
  77. package/dist/runtime/progress.d.ts +21 -0
  78. package/dist/runtime/progress.d.ts.map +1 -1
  79. package/dist/runtime/progress.js +63 -21
  80. package/dist/runtime/progress.js.map +1 -1
  81. package/dist/runtime/query-capabilities.d.ts +24 -0
  82. package/dist/runtime/query-capabilities.d.ts.map +1 -0
  83. package/dist/runtime/query-capabilities.js +66 -0
  84. package/dist/runtime/query-capabilities.js.map +1 -0
  85. package/dist/runtime/run.d.ts +14 -1
  86. package/dist/runtime/run.d.ts.map +1 -1
  87. package/dist/runtime/run.js +198 -39
  88. package/dist/runtime/run.js.map +1 -1
  89. package/dist/runtime/runs.d.ts +23 -0
  90. package/dist/runtime/runs.d.ts.map +1 -0
  91. package/dist/runtime/runs.js +53 -0
  92. package/dist/runtime/runs.js.map +1 -0
  93. package/dist/runtime/upload.d.ts +1 -0
  94. package/dist/runtime/upload.d.ts.map +1 -1
  95. package/dist/runtime/upload.js.map +1 -1
  96. package/dist/runtime/wait.d.ts +32 -0
  97. package/dist/runtime/wait.d.ts.map +1 -0
  98. package/dist/runtime/wait.js +125 -0
  99. package/dist/runtime/wait.js.map +1 -0
  100. package/dist/runtime/world.d.ts.map +1 -1
  101. package/dist/runtime/world.js +2 -0
  102. package/dist/runtime/world.js.map +1 -1
  103. package/dist/spec/conventions.d.ts +10 -2
  104. package/dist/spec/conventions.d.ts.map +1 -1
  105. package/dist/spec/conventions.js +61 -7
  106. package/dist/spec/conventions.js.map +1 -1
  107. package/dist/spec/extensions.d.ts +27 -0
  108. package/dist/spec/extensions.d.ts.map +1 -1
  109. package/dist/spec/extensions.js +40 -5
  110. package/dist/spec/extensions.js.map +1 -1
  111. package/dist/spec/graph.d.ts +2 -1
  112. package/dist/spec/graph.d.ts.map +1 -1
  113. package/dist/spec/graph.js +2 -1
  114. package/dist/spec/graph.js.map +1 -1
  115. package/dist/spec/query-capabilities.d.ts +154 -0
  116. package/dist/spec/query-capabilities.d.ts.map +1 -0
  117. package/dist/spec/query-capabilities.js +734 -0
  118. package/dist/spec/query-capabilities.js.map +1 -0
  119. package/labs/annotated-openapi.yaml +21 -2
  120. package/labs/demo.mjs +4 -2
  121. package/package.json +4 -2
@@ -5,14 +5,17 @@
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";
12
13
  import { STRING_PAYLOADS, payloadFits } from "./payloads.js";
13
14
  import { describeFeatureGate, isDocumentedFeatureGateDenial, reportFeatureGateSchemaDrift } from "./feature-gate.js";
14
15
  import { driveAsync, inspectStreamAsync, matchesPredicate, resolveAsyncId } from "./async.js";
16
+ import { resolveOutOfBandValue } from "./auth.js";
15
17
  import { buildCohort } from "./fixture.js";
18
+ import { driveWait } from "./wait.js";
16
19
  import { fillPath } from "./world.js";
17
20
  /* ------------------------------------------------------------------- helpers */
18
21
  /**
@@ -84,6 +87,8 @@ function q(ctx, roles) {
84
87
  out[c.order] = roles.order;
85
88
  if (roles.search !== undefined && c.search !== undefined)
86
89
  out[c.search] = roles.search;
90
+ if (roles.searchMode !== undefined && c.searchMode !== undefined)
91
+ out[c.searchMode] = roles.searchMode;
87
92
  if (roles.filter !== undefined && c.filter !== undefined)
88
93
  out[c.filter] = roles.filter;
89
94
  return out;
@@ -108,6 +113,48 @@ function filterable(ctx) {
108
113
  * which fields are filterable — otherwise oat would be guessing at parameter names. */
109
114
  return c.grammar === "equality" && (ctx.query?.filterable.length ?? 0) > 0;
110
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
+ }
111
158
  /**
112
159
  * Whether the endpoint can be walked forward at all, however it counts.
113
160
  *
@@ -161,6 +208,7 @@ function linkRelations(exchange) {
161
208
  async function list(ctx, query = {}, auth = ctx.auth, scope = ctx.scope) {
162
209
  const exchange = await ctx.client.get(fillPath(ctx.listOp.path, scope), {
163
210
  headers: auth(),
211
+ operationId: ctx.listOp.operationId,
164
212
  query,
165
213
  });
166
214
  const body = exchange.responseBody;
@@ -312,15 +360,49 @@ function nullableField(ctx, candidates) {
312
360
  return null;
313
361
  }
314
362
  function firstFilterable(ctx, predicate) {
315
- const candidates = ctx.query?.filterable ?? [];
316
- 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;
317
398
  }
318
399
  /**
319
400
  * Finds a record holding non-null values on two distinct, non-identity filterable fields — the
320
401
  * minimum needed to build a compound predicate that is guaranteed to match at least that record.
321
402
  */
322
403
  function twoFilterableFields(ctx) {
323
- 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));
324
406
  for (const target of ctx.records) {
325
407
  const present = candidates.filter((f) => target[f] !== null && target[f] !== undefined);
326
408
  if (present[0] !== undefined && present[1] !== undefined) {
@@ -484,7 +566,7 @@ const unknownFilterRejected = {
484
566
  },
485
567
  };
486
568
  const equalityFilterSelectsOne = {
487
- 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),
488
570
  dependsOn: ["list.read-after-write"],
489
571
  id: "filter.equality-selects-exactly-one",
490
572
  needs: "a `filter` parameter that accepts the identity field",
@@ -493,7 +575,8 @@ const equalityFilterSelectsOne = {
493
575
  if (target === undefined)
494
576
  return;
495
577
  const id = String(target[ctx.identity]);
496
- const term = filterTerm(conv(ctx), ctx.identity, "eq", id);
578
+ const field = filterIdentity(ctx);
579
+ const term = filterTerm(conv(ctx), field, "eq", id);
497
580
  if (term === null)
498
581
  return;
499
582
  const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
@@ -505,16 +588,16 @@ const equalityFilterSelectsOne = {
505
588
  const got = ids(result.items, ctx.identity);
506
589
  if (got.length === 1 && got[0] === id)
507
590
  return;
508
- 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]);
509
592
  },
510
593
  };
511
594
  const zeroMatchFilter = {
512
- applicable: (ctx) => filterable(ctx) && (ctx.query?.filterable.includes(ctx.identity) ?? false),
595
+ applicable: (ctx) => filterable(ctx) && identityIsFilterable(ctx),
513
596
  dependsOn: ["list.read-after-write"],
514
597
  id: "filter.zero-match-returns-none",
515
598
  needs: "a `filter` parameter that accepts the identity field",
516
599
  async run(ctx) {
517
- 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");
518
601
  if (term === null)
519
602
  return;
520
603
  const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
@@ -527,7 +610,7 @@ const zeroMatchFilter = {
527
610
  },
528
611
  };
529
612
  const negationPartitions = {
530
- 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),
531
614
  dependsOn: [
532
615
  "list.read-after-write",
533
616
  /* Partitioning is asserted over field *values*. A backend that drops submitted fields
@@ -545,7 +628,7 @@ const negationPartitions = {
545
628
  return;
546
629
  /* Prefer a field with nulls in the cohort: partitioning on the identity can never expose
547
630
  * three-valued-logic bugs, because an identity is never null. */
548
- const field = nullableField(ctx, ctx.query?.filterable ?? []) ?? ctx.identity;
631
+ const field = nullableField(ctx, filterableNames(ctx)) ?? filterIdentity(ctx);
549
632
  const probe = ctx.records.map((r) => r[field]).find((v) => v !== null && v !== undefined);
550
633
  if (probe === undefined) {
551
634
  return ctx.findings.unresolved(this.id, ctx.entityName, `every record in the cohort has a null "${field}", leaving no value to negate`);
@@ -920,13 +1003,13 @@ const countMatchesWalk = {
920
1003
  id: "count.matches-filtered-set",
921
1004
  needs: "a total-count field and a `filter` parameter",
922
1005
  async run(ctx) {
923
- if (!(ctx.query?.filterable.includes(ctx.identity) ?? false))
1006
+ if (!identityIsFilterable(ctx))
924
1007
  return;
925
1008
  const target = ctx.records[0];
926
1009
  if (target === undefined)
927
1010
  return;
928
1011
  const id = String(target[ctx.identity]);
929
- const countTerm = filterTerm(conv(ctx), ctx.identity, "eq", id);
1012
+ const countTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
930
1013
  if (countTerm === null)
931
1014
  return;
932
1015
  const filtered = await list(ctx, { ...q(ctx, { limit: 100 }), ...countTerm });
@@ -1463,6 +1546,7 @@ const projectionsAgree = {
1463
1546
  * disagreement here is that same defect seen a second time. */
1464
1547
  "filter.equality-selects-exactly-one",
1465
1548
  "select.projection-honoured",
1549
+ "select.requested-fields-present",
1466
1550
  "sort.order-is-applied",
1467
1551
  ],
1468
1552
  id: "consistency.projections-agree",
@@ -2453,10 +2537,10 @@ const crossTenantFilterBypass = {
2453
2537
  const target = ctx.records[0];
2454
2538
  if (target === undefined || ctx.altAuth === undefined || ctx.altScope === undefined)
2455
2539
  return;
2456
- if (!(ctx.query?.filterable.includes(ctx.identity) ?? false))
2540
+ if (!identityIsFilterable(ctx))
2457
2541
  return;
2458
2542
  const id = String(target[ctx.identity]);
2459
- const tenantTerm = filterTerm(conv(ctx), ctx.identity, "eq", id);
2543
+ const tenantTerm = filterTerm(conv(ctx), filterIdentity(ctx), "eq", id);
2460
2544
  if (tenantTerm === null)
2461
2545
  return;
2462
2546
  const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...tenantTerm }, ctx.altAuth, ctx.altScope);
@@ -2601,6 +2685,7 @@ const inviteGrantsThenRevokes = {
2601
2685
  const invited = await ctx.client.request(inviteOp.method, fillPath(inviteOp.path, resource), {
2602
2686
  body: { [spec.granteeField]: delegate.inviteAs },
2603
2687
  headers: { ...owner.headers(), "content-type": "application/json" },
2688
+ operationId: inviteOp.operationId,
2604
2689
  });
2605
2690
  if (standDownForFeatureGate(ctx, inviteOp, invited, this.id))
2606
2691
  return;
@@ -2612,10 +2697,25 @@ const inviteGrantsThenRevokes = {
2612
2697
  "step is then theatre — anyone who can be named in an invite body is already in.", [invited]);
2613
2698
  return;
2614
2699
  }
2615
- const token = pointerValue(invited.responseBody, spec.tokenPointer);
2616
2700
  const grantId = pointerValue(invited.responseBody, spec.grantPointer);
2617
- if (token === undefined) {
2618
- return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
2701
+ let token;
2702
+ if (spec.tokenFrom === "outOfBand") {
2703
+ const kind = spec.tokenKind ?? `${ctx.entityName}-invite`;
2704
+ try {
2705
+ token = await resolveOutOfBandValue(ctx.hooks.resolveOutOfBand, delegate.inviteAs, kind, {
2706
+ label: `invite to ${ctx.entityName}`,
2707
+ outOfBand: ctx.outOfBand,
2708
+ });
2709
+ }
2710
+ catch (error) {
2711
+ return ctx.findings.unresolved(this.id, ctx.entityName, error instanceof Error ? error.message : String(error));
2712
+ }
2713
+ }
2714
+ else {
2715
+ token = pointerValue(invited.responseBody, spec.tokenPointer);
2716
+ if (token === undefined) {
2717
+ return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
2718
+ }
2619
2719
  }
2620
2720
  const acceptScope = { ...resource, token };
2621
2721
  const acceptBody = documentedJsonBody(ctx, acceptOp, acceptScope);
@@ -2625,6 +2725,7 @@ const inviteGrantsThenRevokes = {
2625
2725
  ...delegate.headers(),
2626
2726
  ...(acceptBody === undefined ? {} : { "content-type": "application/json" }),
2627
2727
  },
2728
+ operationId: acceptOp.operationId,
2628
2729
  });
2629
2730
  if (standDownForFeatureGate(ctx, acceptOp, accepted, this.id))
2630
2731
  return;
@@ -2647,6 +2748,7 @@ const inviteGrantsThenRevokes = {
2647
2748
  ...owner.headers(),
2648
2749
  ...(revokeBody === undefined ? {} : { "content-type": "application/json" }),
2649
2750
  },
2751
+ operationId: revokeOp.operationId,
2650
2752
  });
2651
2753
  if (standDownForFeatureGate(ctx, revokeOp, revoked, this.id))
2652
2754
  return;
@@ -2758,6 +2860,15 @@ const orderChangesResult = {
2758
2860
  "backend ignores silently gives every caller arbitrary ordering.", [ascending.exchange]);
2759
2861
  },
2760
2862
  };
2863
+ function numericLexicalDisagrees(ctx, field) {
2864
+ const values = ctx.records.map((row) => row[field]).filter((v) => typeof v === "number");
2865
+ if (values.length < 3)
2866
+ return false;
2867
+ const unique = [...new Set(values)];
2868
+ const asNumbers = [...unique].sort((a, b) => a - b);
2869
+ const asText = [...unique].sort((a, b) => String(a).localeCompare(String(b)));
2870
+ return asNumbers.join(",") !== asText.join(",");
2871
+ }
2761
2872
  function compareValues(a, b) {
2762
2873
  if (a === null || a === undefined)
2763
2874
  return -1;
@@ -3396,6 +3507,61 @@ const declaredEffectsOccur = {
3396
3507
  }
3397
3508
  },
3398
3509
  };
3510
+ /**
3511
+ * After a write that declares `x-wait`, poll the named GET until a JSON path is occupied
3512
+ * (or `awaitSideEffect` returns true). Timeout is a backend finding, not a coverage gap.
3513
+ */
3514
+ const sideEffectArrives = {
3515
+ applicable: (ctx) => ctx.waitOps.length > 0,
3516
+ dependsOn: ["list.read-after-write"],
3517
+ mutates: true,
3518
+ id: "effects.side-effect-arrives",
3519
+ needs: "an operation declaring x-wait",
3520
+ async run(ctx) {
3521
+ for (const op of ctx.waitOps) {
3522
+ const spec = op.wait;
3523
+ if (spec === null)
3524
+ continue;
3525
+ const pollOp = ctx.model.byOperationId.get(spec.operationId);
3526
+ if (pollOp === undefined) {
3527
+ ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} x-wait names "${spec.operationId}", which is not in the document`, "the side effect cannot be observed, so it is not verified");
3528
+ continue;
3529
+ }
3530
+ const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3531
+ const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3532
+ headers: ctx.auth(),
3533
+ operationId: op.operationId,
3534
+ ...(body === undefined ? {} : await encodeOpBody(ctx, op, body)),
3535
+ });
3536
+ if (standDownForFeatureGate(ctx, op, invoked, this.id))
3537
+ continue;
3538
+ if (standDownForRateLimit(ctx, invoked, this.id))
3539
+ continue;
3540
+ if (invoked.status >= 400) {
3541
+ ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared x-wait is unverified`);
3542
+ continue;
3543
+ }
3544
+ const outcome = await driveWait({
3545
+ awaitSideEffect: ctx.hooks.awaitSideEffect,
3546
+ client: ctx.client,
3547
+ headers: ctx.auth,
3548
+ pollOp,
3549
+ record: invoked.responseBody,
3550
+ scope: ctx.scope,
3551
+ spec,
3552
+ writeOpId: op.operationId,
3553
+ ...(ctx.refreshIfStale === undefined ? {} : { refreshIfStale: ctx.refreshIfStale }),
3554
+ });
3555
+ if (!outcome.timedOut)
3556
+ continue;
3557
+ ctx.findings.backend(this.id, ctx.entityName, `${op.operationId} side effect did not appear within ${spec.timeoutMs}ms`, `x-wait polls ${spec.operationId}` +
3558
+ (spec.until === undefined ? "" : ` until ${spec.until} is non-empty`) +
3559
+ ` and the path was still empty after ${outcome.polls} poll(s) / ${Math.round(outcome.elapsedMs)}ms. ` +
3560
+ "Queue consumers and webhook inboxes are not the same request; a timeout here is a " +
3561
+ "missed delivery, not a coverage gap.", [invoked, ...outcome.exchanges.slice(-2)]);
3562
+ }
3563
+ },
3564
+ };
3399
3565
  async function observe(ctx, listOp) {
3400
3566
  let path;
3401
3567
  try {
@@ -3781,6 +3947,1199 @@ const documentedStatusHonoured = {
3781
3947
  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);
3782
3948
  },
3783
3949
  };
3950
+ function pickFieldForOp(ctx, op, minDistinct = 2) {
3951
+ for (const field of fieldsAllowing(ctx, op)) {
3952
+ const values = distinctValues(ctx.records, field.field);
3953
+ if (values.length >= minDistinct)
3954
+ return { field, values };
3955
+ }
3956
+ return null;
3957
+ }
3958
+ function pickOrderedField(ctx) {
3959
+ const caps = resolvedCaps(ctx);
3960
+ for (const field of caps.filterable) {
3961
+ if (field.type !== undefined && !ORDERED_TYPES.has(field.type))
3962
+ continue;
3963
+ if (!canUseOp(ctx, field, "gt") || !canUseOp(ctx, field, "lt") || !canUseOp(ctx, field, "eq"))
3964
+ continue;
3965
+ const values = ctx.records.map((r) => r[field.field]).filter((v) => typeof v === "number");
3966
+ if (values.length < 3)
3967
+ continue;
3968
+ return { field, values };
3969
+ }
3970
+ return null;
3971
+ }
3972
+ const FOUNDATIONS = [
3973
+ "list.read-after-write",
3974
+ "create.persists-submitted-fields",
3975
+ "pagination.page-walk-covers-set",
3976
+ ];
3977
+ const filterInIsUnionOfEq = {
3978
+ applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 2) !== null,
3979
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
3980
+ id: "filter.in-is-union-of-eq",
3981
+ needs: "a field that allows `in` and at least two distinct values",
3982
+ async run(ctx) {
3983
+ const picked = pickFieldForOp(ctx, "in", 2);
3984
+ if (picked === null)
3985
+ return;
3986
+ const [a, b] = picked.values;
3987
+ if (a === undefined || b === undefined)
3988
+ return;
3989
+ const conventions = conv(ctx);
3990
+ const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(a), asTermValue(b)]);
3991
+ const eqA = filterTerm(conventions, picked.field.field, "eq", asTermValue(a));
3992
+ const eqB = filterTerm(conventions, picked.field.field, "eq", asTermValue(b));
3993
+ if (inTerm === null || eqA === null || eqB === null)
3994
+ return;
3995
+ const limit = ctx.query?.maxLimit ?? 100;
3996
+ const together = await collectSet(ctx, limit, inTerm);
3997
+ const onlyA = await collectSet(ctx, limit, eqA);
3998
+ const onlyB = await collectSet(ctx, limit, eqB);
3999
+ if (together === null || onlyA === null || onlyB === null) {
4000
+ return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for in() was rejected");
4001
+ }
4002
+ if (!together.complete || !onlyA.complete || !onlyB.complete) {
4003
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4004
+ }
4005
+ const expected = new Set([...ids(onlyA.items, ctx.identity), ...ids(onlyB.items, ctx.identity)]);
4006
+ const got = setOf(together.items, ctx.identity);
4007
+ if (sameSet(expected, got))
4008
+ return;
4009
+ 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); ` +
4010
+ `eq on each value together match ${expected.size}.`, [together.last.exchange, onlyA.last.exchange, onlyB.last.exchange]);
4011
+ },
4012
+ };
4013
+ const filterNinComplementsIn = {
4014
+ applicable: (ctx) => filterable(ctx) && pickFieldForOp(ctx, "in", 1) !== null && pickFieldForOp(ctx, "nin", 1) !== null,
4015
+ dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
4016
+ id: "filter.nin-complements-in",
4017
+ needs: "a field that allows both `in` and `nin`",
4018
+ async run(ctx) {
4019
+ const picked = pickFieldForOp(ctx, "in", 1);
4020
+ if (picked === null || !canUseOp(ctx, picked.field, "nin"))
4021
+ return;
4022
+ const value = picked.values[0];
4023
+ if (value === undefined)
4024
+ return;
4025
+ const conventions = conv(ctx);
4026
+ const inTerm = filterTerm(conventions, picked.field.field, "in", [asTermValue(value)]);
4027
+ const ninTerm = filterTerm(conventions, picked.field.field, "nin", [asTermValue(value)]);
4028
+ if (inTerm === null || ninTerm === null)
4029
+ return;
4030
+ const limit = ctx.query?.maxLimit ?? 100;
4031
+ const all = await collectSet(ctx, limit);
4032
+ const inside = await collectSet(ctx, limit, inTerm);
4033
+ const outside = await collectSet(ctx, limit, ninTerm);
4034
+ if (all === null || inside === null || outside === null) {
4035
+ return ctx.findings.unresolved(this.id, ctx.entityName, "one of the listings needed for nin() was rejected");
4036
+ }
4037
+ if (!all.complete || !inside.complete || !outside.complete) {
4038
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4039
+ }
4040
+ const inIds = setOf(inside.items, ctx.identity);
4041
+ const ninIds = setOf(outside.items, ctx.identity);
4042
+ const overlap = [...inIds].filter((id) => ninIds.has(id));
4043
+ if (overlap.length > 0) {
4044
+ 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]);
4045
+ return;
4046
+ }
4047
+ const expected = ids(all.items, ctx.identity).filter((id) => {
4048
+ const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
4049
+ return record !== undefined && record[picked.field.field] !== null && record[picked.field.field] !== undefined;
4050
+ });
4051
+ const union = new Set([...inIds, ...ninIds]);
4052
+ const missing = expected.filter((id) => !union.has(id));
4053
+ if (missing.length === 0)
4054
+ return;
4055
+ 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]);
4056
+ },
4057
+ };
4058
+ const filterGteIsGtOrEq = {
4059
+ applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "gte").length > 0,
4060
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4061
+ id: "filter.gte-is-gt-or-eq",
4062
+ needs: "an ordered field that allows `gte` and `gt`",
4063
+ async run(ctx) {
4064
+ const picked = pickOrderedField(ctx);
4065
+ if (picked === null || !canUseOp(ctx, picked.field, "gte"))
4066
+ return;
4067
+ const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
4068
+ if (threshold === undefined)
4069
+ return;
4070
+ await assertRangeUnion(ctx, this.id, picked.field.field, "gte", "gt", threshold);
4071
+ },
4072
+ };
4073
+ const filterLteIsLtOrEq = {
4074
+ applicable: (ctx) => pickOrderedField(ctx) !== null && fieldsAllowing(ctx, "lte").length > 0,
4075
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4076
+ id: "filter.lte-is-lt-or-eq",
4077
+ needs: "an ordered field that allows `lte` and `lt`",
4078
+ async run(ctx) {
4079
+ const picked = pickOrderedField(ctx);
4080
+ if (picked === null || !canUseOp(ctx, picked.field, "lte"))
4081
+ return;
4082
+ const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 3)];
4083
+ if (threshold === undefined)
4084
+ return;
4085
+ await assertRangeUnion(ctx, this.id, picked.field.field, "lte", "lt", threshold);
4086
+ },
4087
+ };
4088
+ async function assertRangeUnion(ctx, check, field, closed, open, threshold) {
4089
+ const conventions = conv(ctx);
4090
+ const closedTerm = filterTerm(conventions, field, closed, threshold);
4091
+ const openTerm = filterTerm(conventions, field, open, threshold);
4092
+ const eqTerm = filterTerm(conventions, field, "eq", threshold);
4093
+ if (closedTerm === null || openTerm === null || eqTerm === null)
4094
+ return;
4095
+ const limit = ctx.query?.maxLimit ?? 100;
4096
+ const closedSet = await collectSet(ctx, limit, closedTerm);
4097
+ const openSet = await collectSet(ctx, limit, openTerm);
4098
+ const eqSet = await collectSet(ctx, limit, eqTerm);
4099
+ if (closedSet === null || openSet === null || eqSet === null) {
4100
+ return ctx.findings.unresolved(check, ctx.entityName, "one of the range listings was rejected");
4101
+ }
4102
+ if (!closedSet.complete || !openSet.complete || !eqSet.complete) {
4103
+ return ctx.findings.unresolved(check, ctx.entityName, "the collection is larger than the walk covers");
4104
+ }
4105
+ const expected = new Set([...ids(openSet.items, ctx.identity), ...ids(eqSet.items, ctx.identity)]);
4106
+ const got = setOf(closedSet.items, ctx.identity);
4107
+ if (sameSet(expected, got))
4108
+ return;
4109
+ 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]);
4110
+ }
4111
+ const filterOrderedTriplePartitions = {
4112
+ applicable: (ctx) => pickOrderedField(ctx) !== null,
4113
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one", "filter.numeric-comparison-is-numeric"],
4114
+ id: "filter.ordered-triple-partitions",
4115
+ needs: "an ordered field that allows `lt`, `eq`, and `gt`",
4116
+ async run(ctx) {
4117
+ const picked = pickOrderedField(ctx);
4118
+ if (picked === null)
4119
+ return;
4120
+ const threshold = [...new Set(picked.values)].sort((a, b) => a - b)[Math.floor(picked.values.length / 2)];
4121
+ if (threshold === undefined)
4122
+ return;
4123
+ const conventions = conv(ctx);
4124
+ const lt = filterTerm(conventions, picked.field.field, "lt", threshold);
4125
+ const eq = filterTerm(conventions, picked.field.field, "eq", threshold);
4126
+ const gt = filterTerm(conventions, picked.field.field, "gt", threshold);
4127
+ if (lt === null || eq === null || gt === null)
4128
+ return;
4129
+ const limit = ctx.query?.maxLimit ?? 100;
4130
+ const all = await collectSet(ctx, limit);
4131
+ const lower = await collectSet(ctx, limit, lt);
4132
+ const equal = await collectSet(ctx, limit, eq);
4133
+ const higher = await collectSet(ctx, limit, gt);
4134
+ if (all === null || lower === null || equal === null || higher === null) {
4135
+ return ctx.findings.unresolved(this.id, ctx.entityName, "one of the triple listings was rejected");
4136
+ }
4137
+ if (!all.complete || !lower.complete || !equal.complete || !higher.complete) {
4138
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4139
+ }
4140
+ const sets = [setOf(lower.items, ctx.identity), setOf(equal.items, ctx.identity), setOf(higher.items, ctx.identity)];
4141
+ for (let i = 0; i < sets.length; i++) {
4142
+ for (let j = i + 1; j < sets.length; j++) {
4143
+ const left = sets[i];
4144
+ const right = sets[j];
4145
+ if (left === undefined || right === undefined)
4146
+ continue;
4147
+ const overlap = [...left].filter((id) => right.has(id));
4148
+ if (overlap.length > 0) {
4149
+ 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]);
4150
+ return;
4151
+ }
4152
+ }
4153
+ }
4154
+ const union = new Set([...(sets[0] ?? []), ...(sets[1] ?? []), ...(sets[2] ?? [])]);
4155
+ const expected = ids(all.items, ctx.identity).filter((id) => {
4156
+ const record = ctx.records.find((row) => String(row[ctx.identity]) === id);
4157
+ return record !== undefined && typeof record[picked.field.field] === "number";
4158
+ });
4159
+ const missing = expected.filter((id) => !union.has(id));
4160
+ if (missing.length === 0)
4161
+ return;
4162
+ 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]);
4163
+ },
4164
+ };
4165
+ const filterIlikeIsCaseInsensitive = {
4166
+ applicable: (ctx) => fieldsAllowing(ctx, "ilike").some((field) => canUseOp(ctx, field, "like")),
4167
+ dependsOn: [...FOUNDATIONS, "filter.like-metacharacters-escaped"],
4168
+ id: "filter.ilike-is-case-insensitive",
4169
+ needs: "a field that allows both `ilike` and `like`, and a string with a letter",
4170
+ async run(ctx) {
4171
+ const field = fieldsAllowing(ctx, "ilike").find((item) => canUseOp(ctx, item, "like"));
4172
+ if (field === undefined)
4173
+ return;
4174
+ const sample = ctx.records
4175
+ .map((row) => row[field.field])
4176
+ .find((value) => typeof value === "string" && /[A-Za-z]/.test(value));
4177
+ if (sample === undefined) {
4178
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no searchable string on the field has a letter");
4179
+ }
4180
+ const flipped = sample.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
4181
+ if (flipped === sample) {
4182
+ return ctx.findings.unresolved(this.id, ctx.entityName, "could not case-flip a letter in the sample");
4183
+ }
4184
+ const conventions = conv(ctx);
4185
+ const likeTerm = filterTerm(conventions, field.field, "like", flipped);
4186
+ const ilikeTerm = filterTerm(conventions, field.field, "ilike", flipped);
4187
+ if (likeTerm === null || ilikeTerm === null)
4188
+ return;
4189
+ const limit = ctx.query?.maxLimit ?? 100;
4190
+ const like = await list(ctx, { ...q(ctx, { limit }), ...likeTerm });
4191
+ const ilike = await list(ctx, { ...q(ctx, { limit }), ...ilikeTerm });
4192
+ if (like.exchange.status >= 400 || ilike.exchange.status >= 400) {
4193
+ return ctx.findings.unresolved(this.id, ctx.entityName, "like/ilike probe was rejected");
4194
+ }
4195
+ const likeHits = ids(like.items, ctx.identity);
4196
+ const ilikeHits = ids(ilike.items, ctx.identity);
4197
+ const original = ctx.records.filter((row) => row[field.field] === sample).map((row) => String(row[ctx.identity]));
4198
+ if (original.some((id) => ilikeHits.includes(id)) && !original.some((id) => likeHits.includes(id)))
4199
+ return;
4200
+ if (original.some((id) => ilikeHits.includes(id)) && original.some((id) => likeHits.includes(id))) {
4201
+ return ctx.findings.unresolved(this.id, ctx.entityName, "like also matched the case-flipped value — the store may already be case-insensitive");
4202
+ }
4203
+ 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]);
4204
+ },
4205
+ };
4206
+ function mixedNullField(ctx, field) {
4207
+ if (ctx.softDelete !== null && field === ctx.softDelete)
4208
+ return false;
4209
+ const values = ctx.records.map((row) => row[field]);
4210
+ const nulls = values.filter((value) => value === null || value === undefined).length;
4211
+ return nulls > 0 && nulls < values.length;
4212
+ }
4213
+ const filterIsNullSelectsNulls = {
4214
+ applicable: (ctx) => fieldsAllowing(ctx, "is").some((field) => mixedNullField(ctx, field.field)),
4215
+ dependsOn: [...FOUNDATIONS],
4216
+ id: "filter.is-null-selects-nulls",
4217
+ needs: "a field that allows `is` and a cohort that contains a null",
4218
+ async run(ctx) {
4219
+ const field = fieldsAllowing(ctx, "is").find((item) => mixedNullField(ctx, item.field));
4220
+ if (field === undefined)
4221
+ return;
4222
+ const conventions = conv(ctx);
4223
+ const nullTerm = filterTerm(conventions, field.field, "is", "null");
4224
+ const notNullTerm = filterTerm(conventions, field.field, "is", "notnull");
4225
+ if (nullTerm === null || notNullTerm === null)
4226
+ return;
4227
+ const limit = ctx.query?.maxLimit ?? 100;
4228
+ const all = await collectSet(ctx, limit);
4229
+ const nulls = await collectSet(ctx, limit, nullTerm);
4230
+ const present = await collectSet(ctx, limit, notNullTerm);
4231
+ if (all === null || nulls === null || present === null) {
4232
+ return ctx.findings.unresolved(this.id, ctx.entityName, "an is.null / is.notnull listing was rejected");
4233
+ }
4234
+ if (!all.complete || !nulls.complete || !present.complete) {
4235
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4236
+ }
4237
+ const expectedNulls = new Set(ctx.records
4238
+ .filter((row) => row[field.field] === null || row[field.field] === undefined)
4239
+ .map((row) => String(row[ctx.identity])));
4240
+ const gotNulls = setOf(nulls.items, ctx.identity);
4241
+ const overlap = [...gotNulls].filter((id) => setOf(present.items, ctx.identity).has(id));
4242
+ if (!sameSet(expectedNulls, gotNulls) || overlap.length > 0) {
4243
+ 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. ` +
4244
+ (overlap.length > 0 ? `${overlap.length} appear on both sides.` : ""), [nulls.last.exchange, present.last.exchange]);
4245
+ }
4246
+ },
4247
+ };
4248
+ const filterContainsMembership = {
4249
+ applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => (field.type === "array" || fieldAllows(field, "contains", resolvedCaps(ctx))) &&
4250
+ canWriteFilterOp(conv(ctx), "contains") &&
4251
+ ctx.records.some((row) => Array.isArray(row[field.field]) && row[field.field].length > 0)),
4252
+ dependsOn: [...FOUNDATIONS],
4253
+ id: "filter.contains-membership",
4254
+ needs: "an array field that allows `contains` and a known element",
4255
+ async run(ctx) {
4256
+ const field = resolvedCaps(ctx).filterable.find((item) => canUseOp(ctx, item, "contains") &&
4257
+ ctx.records.some((row) => Array.isArray(row[item.field]) && row[item.field].length > 0));
4258
+ if (field === undefined)
4259
+ return;
4260
+ const sample = ctx.records.find((row) => Array.isArray(row[field.field]) && row[field.field].length > 0);
4261
+ const element = Array.isArray(sample?.[field.field]) ? sample[field.field][0] : undefined;
4262
+ if (element === undefined)
4263
+ return;
4264
+ const term = filterTerm(conv(ctx), field.field, "contains", asTermValue(element));
4265
+ if (term === null)
4266
+ return;
4267
+ const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, term);
4268
+ if (result === null) {
4269
+ return ctx.findings.unresolved(this.id, ctx.entityName, "contains probe was rejected");
4270
+ }
4271
+ if (!result.complete) {
4272
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4273
+ }
4274
+ const expected = new Set(ctx.records
4275
+ .filter((row) => Array.isArray(row[field.field]) &&
4276
+ row[field.field].some((item) => String(item) === String(element)))
4277
+ .map((row) => String(row[ctx.identity])));
4278
+ const got = setOf(result.items, ctx.identity);
4279
+ if (sameSet(expected, got))
4280
+ return;
4281
+ 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]);
4282
+ },
4283
+ };
4284
+ const filterNestedAndOrDistributes = {
4285
+ applicable: (ctx) => conv(ctx).grammar === "postgrest" && filterableNames(ctx).length > 1,
4286
+ dependsOn: [...FOUNDATIONS, "filter.and-composes-as-intersection", "filter.or-composes-as-union"],
4287
+ id: "filter.nested-and-or-distributes",
4288
+ needs: "a postgrest-shaped grammar and two filterable fields",
4289
+ async run(ctx) {
4290
+ const picked = twoFilterableFields(ctx);
4291
+ if (picked === null) {
4292
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no record holds two non-null filterable fields");
4293
+ }
4294
+ const idField = filterIdentity(ctx);
4295
+ if (idField === picked.fieldA || idField === picked.fieldB || !identityIsFilterable(ctx)) {
4296
+ return ctx.findings.unresolved(this.id, ctx.entityName, "need a third identity filter term to nest and/or");
4297
+ }
4298
+ const conventions = conv(ctx);
4299
+ const fragA = filterFragment(conventions, picked.fieldA, String(picked.target[picked.fieldA]));
4300
+ const fragB = filterFragment(conventions, picked.fieldB, String(picked.target[picked.fieldB]));
4301
+ const fragC = filterFragment(conventions, idField, String(picked.target[ctx.identity]));
4302
+ if (fragA === null || fragB === null || fragC === null || conventions.filter === undefined)
4303
+ return;
4304
+ const nested = { [conventions.filter]: `and(${fragA},or(${fragB},${fragC}))` };
4305
+ const termA = filterTerm(conventions, picked.fieldA, "eq", String(picked.target[picked.fieldA]));
4306
+ const termB = filterTerm(conventions, picked.fieldB, "eq", String(picked.target[picked.fieldB]));
4307
+ const termC = filterTerm(conventions, idField, "eq", String(picked.target[ctx.identity]));
4308
+ if (termA === null || termB === null || termC === null)
4309
+ return;
4310
+ const limit = ctx.query?.maxLimit ?? 100;
4311
+ const onlyA = await collectSet(ctx, limit, termA);
4312
+ const onlyB = await collectSet(ctx, limit, termB);
4313
+ const onlyC = await collectSet(ctx, limit, termC);
4314
+ const combined = await collectSet(ctx, limit, nested);
4315
+ if (onlyA === null || onlyB === null || onlyC === null || combined === null) {
4316
+ return ctx.findings.unresolved(this.id, ctx.entityName, "a nested and/or listing was rejected");
4317
+ }
4318
+ if (!onlyA.complete || !onlyB.complete || !onlyC.complete || !combined.complete) {
4319
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers");
4320
+ }
4321
+ const setA = setOf(onlyA.items, ctx.identity);
4322
+ const setB = setOf(onlyB.items, ctx.identity);
4323
+ const setC = setOf(onlyC.items, ctx.identity);
4324
+ const expected = new Set([...setA].filter((id) => setB.has(id) || setC.has(id)));
4325
+ const got = setOf(combined.items, ctx.identity);
4326
+ if (sameSet(expected, got))
4327
+ return;
4328
+ 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]);
4329
+ },
4330
+ };
4331
+ const filterAliasMatchesCanonical = {
4332
+ applicable: (ctx) => Object.keys(resolvedCaps(ctx).aliases).length > 0 && filterable(ctx),
4333
+ dependsOn: [...FOUNDATIONS, "filter.equality-selects-exactly-one"],
4334
+ id: "filter.alias-matches-canonical",
4335
+ needs: "a declared filter operator alias",
4336
+ async run(ctx) {
4337
+ const caps = resolvedCaps(ctx);
4338
+ const conventions = conv(ctx);
4339
+ const limit = ctx.query?.maxLimit ?? 100;
4340
+ for (const [alias, target] of Object.entries(caps.aliases)) {
4341
+ if (!isFilterOp(alias) || target === undefined)
4342
+ continue;
4343
+ const field = resolvedCaps(ctx).filterable.find((item) => fieldAllows(item, target, caps));
4344
+ if (field === undefined || !canWriteFilterOp(conventions, alias))
4345
+ continue;
4346
+ const sample = ctx.records.find((row) => row[field.field] != null);
4347
+ if (sample === undefined)
4348
+ continue;
4349
+ const value = asTermValue(sample[field.field]);
4350
+ const aliasTerm = filterTerm(conventions, field.field, alias, value);
4351
+ const targetTerm = filterTerm(conventions, field.field, target, value);
4352
+ if (aliasTerm === null || targetTerm === null)
4353
+ continue;
4354
+ const left = await collectSet(ctx, limit, aliasTerm);
4355
+ const right = await collectSet(ctx, limit, targetTerm);
4356
+ if (left === null || right === null) {
4357
+ return ctx.findings.unresolved(this.id, ctx.entityName, `alias ${alias} or ${target} was rejected`);
4358
+ }
4359
+ if (!sameSet(setOf(left.items, ctx.identity), setOf(right.items, ctx.identity))) {
4360
+ 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]);
4361
+ return;
4362
+ }
4363
+ }
4364
+ },
4365
+ };
4366
+ function firstIllegalOp(ctx, field) {
4367
+ const caps = resolvedCaps(ctx);
4368
+ if (!opsAreClosed(field, caps))
4369
+ return undefined;
4370
+ const allowed = new Set(opsForField(field, caps));
4371
+ return FILTER_OPS.find((op) => !allowed.has(op) && canWriteFilterOp(conv(ctx), op));
4372
+ }
4373
+ const filterIllegalOpRejected = {
4374
+ applicable: (ctx) => resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
4375
+ dependsOn: ["list.read-after-write", "filter.unknown-field-rejected", "error.malformed-filter-not-5xx"],
4376
+ id: "filter.illegal-op-rejected",
4377
+ needs: "a field with a closed operator list",
4378
+ async run(ctx) {
4379
+ const field = resolvedCaps(ctx).filterable.find((item) => firstIllegalOp(ctx, item) !== undefined);
4380
+ const op = field === undefined ? undefined : firstIllegalOp(ctx, field);
4381
+ if (field === undefined || op === undefined)
4382
+ return;
4383
+ const sample = ctx.records.find((row) => row[field.field] != null);
4384
+ const term = filterTerm(conv(ctx), field.field, op, sample === undefined ? "oat-probe" : asTermValue(sample[field.field]));
4385
+ if (term === null)
4386
+ return;
4387
+ const baseline = await list(ctx, q(ctx, { limit: 100 }));
4388
+ const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
4389
+ if (result.exchange.status >= 500) {
4390
+ 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]);
4391
+ return;
4392
+ }
4393
+ if (result.exchange.status >= 400)
4394
+ return;
4395
+ const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
4396
+ ctx.findings.backend(this.id, ctx.entityName, "an illegal filter operator is accepted", `${field.field}.${op} returned ${result.exchange.status}` +
4397
+ (same ? " with the unfiltered set, so the operator was ignored" : "") +
4398
+ ". A closed operator list is a contract: anything outside it must be rejected.", [baseline.exchange, result.exchange]);
4399
+ },
4400
+ };
4401
+ const filterEmptyIn = {
4402
+ applicable: (ctx) => resolvedCaps(ctx).emptyIn !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
4403
+ dependsOn: [...FOUNDATIONS, "filter.in-is-union-of-eq"],
4404
+ id: "filter.empty-in",
4405
+ needs: "`emptyIn` declared and a field that allows `in`",
4406
+ async run(ctx) {
4407
+ const picked = pickFieldForOp(ctx, "in", 1);
4408
+ const policy = resolvedCaps(ctx).emptyIn;
4409
+ if (picked === null || policy === undefined)
4410
+ return;
4411
+ const term = filterTerm(conv(ctx), picked.field.field, "in", []);
4412
+ if (term === null)
4413
+ return;
4414
+ const baseline = await list(ctx, q(ctx, { limit: 100 }));
4415
+ const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...term });
4416
+ if (policy === "reject") {
4417
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4418
+ return;
4419
+ 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]);
4420
+ return;
4421
+ }
4422
+ if (result.exchange.status >= 400) {
4423
+ return ctx.findings.unresolved(this.id, ctx.entityName, "empty in() was rejected under match-none");
4424
+ }
4425
+ if (result.items.length === 0)
4426
+ return;
4427
+ const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
4428
+ 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)` +
4429
+ (same ? " — the unfiltered set, so the filter was ignored" : "") +
4430
+ ".", [baseline.exchange, result.exchange]);
4431
+ },
4432
+ };
4433
+ const filterInOverLimitRejected = {
4434
+ applicable: (ctx) => resolvedCaps(ctx).maxInValues !== undefined && pickFieldForOp(ctx, "in", 1) !== null,
4435
+ dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
4436
+ id: "filter.in-over-limit-rejected",
4437
+ needs: "`maxInValues` declared and a field that allows `in`",
4438
+ async run(ctx) {
4439
+ const max = resolvedCaps(ctx).maxInValues;
4440
+ const picked = pickFieldForOp(ctx, "in", 1);
4441
+ if (max === undefined || picked === null)
4442
+ return;
4443
+ const members = Array.from({ length: max + 1 }, (_, i) => `oat-over-limit-${i}`);
4444
+ const term = filterTerm(conv(ctx), picked.field.field, "in", members);
4445
+ if (term === null)
4446
+ return;
4447
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
4448
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4449
+ return;
4450
+ 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]);
4451
+ },
4452
+ };
4453
+ const filterConditionCapRejected = {
4454
+ applicable: (ctx) => resolvedCaps(ctx).maxFilterConditions !== undefined &&
4455
+ conv(ctx).grammar === "postgrest" &&
4456
+ identityIsFilterable(ctx),
4457
+ dependsOn: [...FOUNDATIONS, "error.malformed-filter-not-5xx"],
4458
+ id: "filter.condition-cap-rejected",
4459
+ needs: "`maxFilterConditions` declared and a grammar that can group eq terms",
4460
+ async run(ctx) {
4461
+ const max = resolvedCaps(ctx).maxFilterConditions;
4462
+ const parameter = conv(ctx).filter;
4463
+ if (max === undefined || parameter === undefined)
4464
+ return;
4465
+ const field = filterIdentity(ctx);
4466
+ const terms = Array.from({ length: max + 1 }, () => `${field}.eq.oat-cap`);
4467
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), [parameter]: `and(${terms.join(",")})` });
4468
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4469
+ return;
4470
+ 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]);
4471
+ },
4472
+ };
4473
+ function probeValueForOp(ctx, field, op) {
4474
+ const sample = ctx.records.find((row) => row[field.field] != null);
4475
+ if (op === "is")
4476
+ return "null";
4477
+ if (op === "in" || op === "nin")
4478
+ return [sample === undefined ? "oat-probe" : asTermValue(sample[field.field])];
4479
+ if (op === "contains") {
4480
+ const arr = sample?.[field.field];
4481
+ if (Array.isArray(arr) && arr[0] !== undefined)
4482
+ return asTermValue(arr[0]);
4483
+ return "oat-probe";
4484
+ }
4485
+ if (sample === undefined)
4486
+ return "oat-probe";
4487
+ return asTermValue(sample[field.field]);
4488
+ }
4489
+ const declaredFilterableOpsAccepted = {
4490
+ applicable: (ctx) => ctx.query?.source === "tag" && resolvedCaps(ctx).filterable.some((field) => opsAreClosed(field, resolvedCaps(ctx))),
4491
+ dependsOn: ["list.read-after-write", "spec.declared-filterable-is-filterable", "error.malformed-filter-not-5xx"],
4492
+ id: "spec.declared-filterable-ops-accepted",
4493
+ needs: "a closed operator list on at least one declared field",
4494
+ async run(ctx) {
4495
+ const caps = resolvedCaps(ctx);
4496
+ const rejected = [];
4497
+ for (const field of caps.filterable) {
4498
+ if (!opsAreClosed(field, caps))
4499
+ continue;
4500
+ for (const op of opsForField(field, caps)) {
4501
+ if (!canWriteFilterOp(conv(ctx), op))
4502
+ continue;
4503
+ const term = filterTerm(conv(ctx), field.field, op, probeValueForOp(ctx, field, op));
4504
+ if (term === null)
4505
+ continue;
4506
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...term });
4507
+ if (result.exchange.status >= 400 && result.exchange.status < 500) {
4508
+ rejected.push(`${field.field}.${op} (${result.exchange.status})`);
4509
+ }
4510
+ }
4511
+ }
4512
+ if (rejected.length === 0)
4513
+ return;
4514
+ 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(", ")}.`, []);
4515
+ },
4516
+ };
4517
+ const declaredFilterableIllegalOpRejected = {
4518
+ applicable: (ctx) => ctx.query?.source === "tag" &&
4519
+ resolvedCaps(ctx).filterable.some((field) => firstIllegalOp(ctx, field) !== undefined),
4520
+ dependsOn: ["filter.illegal-op-rejected", "spec.declared-filterable-is-filterable"],
4521
+ id: "spec.declared-filterable-illegal-op-rejected",
4522
+ needs: "a closed operator list on a declared field",
4523
+ async run(ctx) {
4524
+ await filterIllegalOpRejected.run(ctx);
4525
+ },
4526
+ };
4527
+ const sortUnknownFieldRejected = {
4528
+ applicable: (ctx) => conv(ctx).order !== undefined,
4529
+ dependsOn: ["error.malformed-filter-not-5xx"],
4530
+ id: "sort.unknown-field-rejected",
4531
+ needs: "an order parameter",
4532
+ async run(ctx) {
4533
+ const result = await list(ctx, q(ctx, { limit: 5, order: sortTerm(conv(ctx), "oat_no_such_sort_xyz", "asc") }));
4534
+ if (result.exchange.status >= 500) {
4535
+ 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]);
4536
+ return;
4537
+ }
4538
+ if (result.exchange.status >= 400)
4539
+ return;
4540
+ const baseline = await list(ctx, q(ctx, { limit: 5 }));
4541
+ const same = ids(result.items, ctx.identity).join(",") === ids(baseline.items, ctx.identity).join(",");
4542
+ ctx.findings.backend(this.id, ctx.entityName, "unknown sort field is silently ignored", `order on an undeclared field returned ${result.exchange.status}` +
4543
+ (same ? " with the default order, so the field was dropped" : "") +
4544
+ ".", [baseline.exchange, result.exchange]);
4545
+ },
4546
+ };
4547
+ const sortNumericOrderIsNumeric = {
4548
+ applicable: (ctx) => conv(ctx).order !== undefined &&
4549
+ resolvedCaps(ctx).sortable.some((field) => numericLexicalDisagrees(ctx, field.field)),
4550
+ dependsOn: ["sort.order-is-applied"],
4551
+ id: "sort.numeric-order-is-numeric",
4552
+ needs: "a numeric sortable field whose lexical order disagrees with numeric order",
4553
+ async run(ctx) {
4554
+ const field = resolvedCaps(ctx).sortable.find((item) => numericLexicalDisagrees(ctx, item.field));
4555
+ if (field === undefined)
4556
+ return;
4557
+ const result = await list(ctx, q(ctx, { limit: ctx.query?.maxLimit ?? 100, order: sortTerm(conv(ctx), field.field, "asc") }));
4558
+ if (result.exchange.status >= 400)
4559
+ return;
4560
+ const numbers = result.items.map((item) => item[field.field]).filter((v) => typeof v === "number");
4561
+ const sorted = [...numbers].sort((a, b) => a - b);
4562
+ if (numbers.join(",") === sorted.join(","))
4563
+ return;
4564
+ 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]);
4565
+ },
4566
+ };
4567
+ const sortNullsFirstLast = {
4568
+ applicable: (ctx) => conv(ctx).order !== undefined &&
4569
+ sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
4570
+ resolvedCaps(ctx).sortable.some((field) => (fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")) &&
4571
+ ctx.records.some((row) => row[field.field] === null || row[field.field] === undefined)),
4572
+ dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
4573
+ id: "sort.nulls-first-last",
4574
+ needs: "a declared nulls token, a dotted sort grammar, and a null in the cohort",
4575
+ async run(ctx) {
4576
+ const field = resolvedCaps(ctx).sortable.find((item) => (fieldAllowsNulls(item, resolvedCaps(ctx), "first") || fieldAllowsNulls(item, resolvedCaps(ctx), "last")) &&
4577
+ ctx.records.some((row) => row[item.field] === null || row[item.field] === undefined));
4578
+ if (field === undefined)
4579
+ return;
4580
+ const limit = ctx.query?.maxLimit ?? 100;
4581
+ if (fieldAllowsNulls(field, resolvedCaps(ctx), "first")) {
4582
+ const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "first");
4583
+ if (clause === null)
4584
+ return;
4585
+ const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
4586
+ if (result === null)
4587
+ return ctx.findings.unresolved(this.id, ctx.entityName, "nullsfirst listing was rejected");
4588
+ const first = result.items[0];
4589
+ if (first !== undefined && first[field.field] !== null && first[field.field] !== undefined) {
4590
+ 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]);
4591
+ return;
4592
+ }
4593
+ }
4594
+ if (fieldAllowsNulls(field, resolvedCaps(ctx), "last")) {
4595
+ const clause = sortTermWithNulls(conv(ctx), field.field, "asc", "last");
4596
+ if (clause === null)
4597
+ return;
4598
+ const result = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, clause);
4599
+ if (result === null)
4600
+ return ctx.findings.unresolved(this.id, ctx.entityName, "nullslast listing was rejected");
4601
+ const last = result.items.at(-1);
4602
+ if (last !== undefined && last[field.field] !== null && last[field.field] !== undefined) {
4603
+ 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]);
4604
+ }
4605
+ }
4606
+ },
4607
+ };
4608
+ function pickMultiKeyPair(ctx) {
4609
+ const fields = ctx.query?.sortable ?? [];
4610
+ for (const primary of fields) {
4611
+ for (const secondary of fields) {
4612
+ if (secondary === primary || secondary === ctx.identity)
4613
+ continue;
4614
+ const groups = new Map();
4615
+ for (const row of ctx.records) {
4616
+ const key = JSON.stringify(row[primary]);
4617
+ const group = groups.get(key) ?? [];
4618
+ group.push(row);
4619
+ groups.set(key, group);
4620
+ }
4621
+ const tied = [...groups.values()].find((group) => group.length >= 2 && new Set(group.map((row) => JSON.stringify(row[secondary]))).size > 1);
4622
+ if (tied !== undefined)
4623
+ return { primary, secondary, tied };
4624
+ }
4625
+ }
4626
+ return null;
4627
+ }
4628
+ const sortMultiKeyTiebreak = {
4629
+ applicable: (ctx) => {
4630
+ const max = resolvedCaps(ctx).sort?.maxKeys;
4631
+ return conv(ctx).order !== undefined && (max === undefined || max >= 2) && pickMultiKeyPair(ctx) !== null;
4632
+ },
4633
+ dependsOn: ["sort.order-is-applied", "sort.reverse-symmetry"],
4634
+ id: "sort.multi-key-tiebreak",
4635
+ needs: "two sortable fields and rows that tie on the first and differ on the second",
4636
+ async run(ctx) {
4637
+ const pair = pickMultiKeyPair(ctx);
4638
+ if (pair === null) {
4639
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no cohort tie on the first key differs on the second");
4640
+ }
4641
+ const { primary, secondary, tied } = pair;
4642
+ const conventions = conv(ctx);
4643
+ const order = `${sortTerm(conventions, primary, "asc")},${sortTerm(conventions, secondary, "asc")}`;
4644
+ const result = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
4645
+ if (result === null)
4646
+ return ctx.findings.unresolved(this.id, ctx.entityName, "multi-key order was rejected");
4647
+ const slice = result.items.filter((item) => JSON.stringify(item[primary]) === JSON.stringify(tied[0]?.[primary]));
4648
+ const seconds = slice.map((item) => item[secondary]);
4649
+ const sorted = [...seconds].sort(compareValues);
4650
+ if (JSON.stringify(seconds) === JSON.stringify(sorted))
4651
+ return;
4652
+ 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]);
4653
+ },
4654
+ };
4655
+ const sortDefaultOrderApplied = {
4656
+ applicable: (ctx) => (resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder) !== undefined && ctx.records.length > 1,
4657
+ dependsOn: ["sort.order-is-applied", "pagination.page-walk-covers-set"],
4658
+ id: "sort.default-order-applied",
4659
+ needs: "a declared defaultOrder and a complete walk",
4660
+ async run(ctx) {
4661
+ const declared = resolvedCaps(ctx).sort?.defaultOrder ?? ctx.query?.defaultOrder;
4662
+ if (declared === undefined)
4663
+ return;
4664
+ const limit = ctx.query?.maxLimit ?? 100;
4665
+ const implicit = await collectSet(ctx, limit);
4666
+ const explicit = await collectSet(ctx, limit, {}, MAX_WALK_PAGES, declared);
4667
+ if (implicit === null || explicit === null) {
4668
+ return ctx.findings.unresolved(this.id, ctx.entityName, "default-order walk was rejected");
4669
+ }
4670
+ if (!implicit.complete || !explicit.complete) {
4671
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the walk is incomplete, so default order cannot be compared");
4672
+ }
4673
+ if (ids(implicit.items, ctx.identity).join(",") === ids(explicit.items, ctx.identity).join(","))
4674
+ return;
4675
+ 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]);
4676
+ },
4677
+ };
4678
+ const sortStableTiebreak = {
4679
+ applicable: (ctx) => (resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak) !== undefined,
4680
+ dependsOn: ["sort.order-is-applied"],
4681
+ id: "sort.stable-tiebreak",
4682
+ needs: "a declared stableTiebreak and a tie on the primary key",
4683
+ async run(ctx) {
4684
+ const tiebreak = resolvedCaps(ctx).sort?.stableTiebreak ?? ctx.query?.stableTiebreak;
4685
+ const primary = ctx.query?.sortable.find((name) => name !== tiebreak) ?? ctx.query?.sortable[0];
4686
+ if (tiebreak === undefined || primary === undefined)
4687
+ return;
4688
+ const order = sortTerm(conv(ctx), primary, "asc");
4689
+ const first = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
4690
+ const second = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {}, MAX_WALK_PAGES, order);
4691
+ if (first === null || second === null)
4692
+ return ctx.findings.unresolved(this.id, ctx.entityName, "repeat sort was rejected");
4693
+ if (ids(first.items, ctx.identity).join(",") === ids(second.items, ctx.identity).join(","))
4694
+ return;
4695
+ 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]);
4696
+ },
4697
+ };
4698
+ const declaredSortableNullsAccepted = {
4699
+ applicable: (ctx) => conv(ctx).order !== undefined &&
4700
+ sortTermWithNulls(conv(ctx), "x", "asc", "first") !== null &&
4701
+ resolvedCaps(ctx).sortable.some((field) => fieldAllowsNulls(field, resolvedCaps(ctx), "first") || fieldAllowsNulls(field, resolvedCaps(ctx), "last")),
4702
+ dependsOn: ["sort.order-is-applied"],
4703
+ id: "spec.declared-sortable-nulls-accepted",
4704
+ needs: "a declared nulls token on a sortable field",
4705
+ async run(ctx) {
4706
+ const rejected = [];
4707
+ for (const field of resolvedCaps(ctx).sortable) {
4708
+ for (const token of ["first", "last"]) {
4709
+ if (!fieldAllowsNulls(field, resolvedCaps(ctx), token))
4710
+ continue;
4711
+ const clause = sortTermWithNulls(conv(ctx), field.field, "asc", token);
4712
+ if (clause === null)
4713
+ continue;
4714
+ const result = await list(ctx, q(ctx, { limit: 5, order: clause }));
4715
+ if (result.exchange.status >= 400 && result.exchange.status < 500) {
4716
+ rejected.push(`${clause} (${result.exchange.status})`);
4717
+ }
4718
+ }
4719
+ }
4720
+ if (rejected.length === 0)
4721
+ return;
4722
+ ctx.findings.spec(this.id, ctx.entityName, "the document declares a nulls sort token the backend rejects", `rejected: ${rejected.join(", ")}.`, []);
4723
+ },
4724
+ };
4725
+ const searchTokensAnd = {
4726
+ applicable: (ctx) => conv(ctx).search !== undefined && (ctx.query?.searchable.length ?? 0) > 0 && ctx.records.length > 2,
4727
+ dependsOn: ["search.q-narrows-result"],
4728
+ id: "search.tokens-and",
4729
+ needs: "searchable fields and two tokens that split the cohort",
4730
+ async run(ctx) {
4731
+ const field = ctx.query?.searchable[0];
4732
+ if (field === undefined)
4733
+ return;
4734
+ const tokens = [];
4735
+ for (const row of ctx.records) {
4736
+ const value = row[field];
4737
+ if (typeof value !== "string")
4738
+ continue;
4739
+ const word = value.split(/\s+/).find((part) => part.length >= 3);
4740
+ if (word !== undefined)
4741
+ tokens.push(word.toLowerCase());
4742
+ }
4743
+ const unique = [...new Set(tokens)];
4744
+ if (unique.length < 2) {
4745
+ return ctx.findings.unresolved(this.id, ctx.entityName, "could not find two discriminating search tokens");
4746
+ }
4747
+ const [a, b] = unique;
4748
+ if (a === undefined || b === undefined)
4749
+ return;
4750
+ const limit = ctx.query?.maxLimit ?? 100;
4751
+ const onlyA = await list(ctx, q(ctx, { limit, search: a }));
4752
+ const onlyB = await list(ctx, q(ctx, { limit, search: b }));
4753
+ const both = await list(ctx, q(ctx, { limit, search: `${a} ${b}` }));
4754
+ if (onlyA.exchange.status >= 400 || onlyB.exchange.status >= 400 || both.exchange.status >= 400) {
4755
+ return ctx.findings.unresolved(this.id, ctx.entityName, "a token search was rejected");
4756
+ }
4757
+ const setA = setOf(onlyA.items, ctx.identity);
4758
+ const setB = setOf(onlyB.items, ctx.identity);
4759
+ const expected = new Set([...setA].filter((id) => setB.has(id)));
4760
+ const got = setOf(both.items, ctx.identity);
4761
+ if (sameSet(expected, got) || sameSet(got, new Set([...setA, ...setB])))
4762
+ return;
4763
+ 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]);
4764
+ },
4765
+ };
4766
+ const searchCaseInsensitive = {
4767
+ applicable: (ctx) => conv(ctx).search !== undefined &&
4768
+ (ctx.query?.searchable.length ?? 0) > 0 &&
4769
+ ctx.records.some((row) => ctx.query?.searchable.some((field) => typeof row[field] === "string" && /[A-Za-z]/.test(row[field]))),
4770
+ dependsOn: ["search.q-narrows-result"],
4771
+ id: "search.case-insensitive",
4772
+ needs: "a searchable string with a letter",
4773
+ async run(ctx) {
4774
+ let sample;
4775
+ for (const field of ctx.query?.searchable ?? []) {
4776
+ const value = ctx.records
4777
+ .map((row) => row[field])
4778
+ .find((item) => typeof item === "string" && /[A-Za-z]/.test(item));
4779
+ if (value !== undefined) {
4780
+ sample = value;
4781
+ break;
4782
+ }
4783
+ }
4784
+ if (sample === undefined)
4785
+ return;
4786
+ const token = sample.split(/\s+/).find((part) => /[A-Za-z]/.test(part)) ?? sample;
4787
+ const flipped = token.replace(/[A-Za-z]/, (ch) => (ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase()));
4788
+ const limit = ctx.query?.maxLimit ?? 100;
4789
+ const original = await list(ctx, q(ctx, { limit, search: token }));
4790
+ const other = await list(ctx, q(ctx, { limit, search: flipped }));
4791
+ if (original.exchange.status >= 400 || other.exchange.status >= 400)
4792
+ return;
4793
+ const a = setOf(original.items, ctx.identity);
4794
+ const b = setOf(other.items, ctx.identity);
4795
+ if (sameSet(a, b))
4796
+ return;
4797
+ if (resolvedCaps(ctx).searchCase === "insensitive") {
4798
+ 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]);
4799
+ return;
4800
+ }
4801
+ ctx.findings.unresolved(this.id, ctx.entityName, "case-flipped search selected a different set; declare searchCase: insensitive to treat that as a defect");
4802
+ },
4803
+ };
4804
+ const searchEmptyQ = {
4805
+ applicable: (ctx) => conv(ctx).search !== undefined && resolvedCaps(ctx).searchEmpty !== undefined,
4806
+ dependsOn: ["search.q-narrows-result"],
4807
+ id: "search.empty-q",
4808
+ needs: "`searchEmpty` declared",
4809
+ async run(ctx) {
4810
+ const policy = resolvedCaps(ctx).searchEmpty;
4811
+ if (policy === undefined)
4812
+ return;
4813
+ const limit = ctx.query?.maxLimit ?? 100;
4814
+ const baseline = await list(ctx, q(ctx, { limit }));
4815
+ const empty = await list(ctx, q(ctx, { limit, search: "" }));
4816
+ if (policy === "reject") {
4817
+ if (empty.exchange.status >= 400 && empty.exchange.status < 500)
4818
+ return;
4819
+ ctx.findings.backend(this.id, ctx.entityName, "empty q was not rejected", `searchEmpty=reject but q= returned ${empty.exchange.status}.`, [empty.exchange]);
4820
+ return;
4821
+ }
4822
+ if (empty.exchange.status >= 400) {
4823
+ return ctx.findings.unresolved(this.id, ctx.entityName, "empty q was rejected");
4824
+ }
4825
+ if (sameSet(setOf(empty.items, ctx.identity), setOf(baseline.items, ctx.identity)))
4826
+ return;
4827
+ 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]);
4828
+ },
4829
+ };
4830
+ const searchUndeclaredFieldNotRequired = {
4831
+ applicable: (ctx) => {
4832
+ if (conv(ctx).search === undefined || (ctx.query?.searchable.length ?? 0) === 0)
4833
+ return false;
4834
+ const searchable = new Set(ctx.query?.searchable ?? []);
4835
+ return ctx.records.some((row) => Object.entries(row).some(([key, value]) => !searchable.has(key) && typeof value === "string" && value.length >= 4 && !searchableHasToken(ctx, value)));
4836
+ },
4837
+ dependsOn: ["search.q-narrows-result"],
4838
+ id: "search.undeclared-field-not-required",
4839
+ needs: "a searchable field and a non-searchable string",
4840
+ async run() {
4841
+ /* Extra recall is not a defect. This check exists so undeclared-field hits are not
4842
+ * reported as SEARCH_IGNORED by other checks. */
4843
+ },
4844
+ };
4845
+ function searchableHasToken(ctx, token) {
4846
+ const needle = token.toLowerCase();
4847
+ return ctx.records.some((row) => (ctx.query?.searchable ?? []).some((field) => String(row[field] ?? "")
4848
+ .toLowerCase()
4849
+ .includes(needle)));
4850
+ }
4851
+ const searchModeAccepted = {
4852
+ applicable: (ctx) => conv(ctx).searchMode !== undefined &&
4853
+ (resolvedCaps(ctx).searchModes?.length ?? 0) > 0 &&
4854
+ conv(ctx).search !== undefined,
4855
+ id: "search.mode-accepted",
4856
+ needs: "declared searchModes and a search-mode parameter",
4857
+ async run(ctx) {
4858
+ const rejected = [];
4859
+ for (const mode of resolvedCaps(ctx).searchModes ?? []) {
4860
+ const result = await list(ctx, q(ctx, { limit: 5, search: "oat", searchMode: mode }));
4861
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4862
+ rejected.push(`${mode} (${result.exchange.status})`);
4863
+ }
4864
+ if (rejected.length === 0)
4865
+ return;
4866
+ ctx.findings.spec(this.id, ctx.entityName, "a declared search mode is rejected", `rejected: ${rejected.join(", ")}.`, []);
4867
+ },
4868
+ };
4869
+ const searchModesDiffer = {
4870
+ applicable: (ctx) => conv(ctx).searchMode !== undefined &&
4871
+ (resolvedCaps(ctx).searchModes?.length ?? 0) >= 2 &&
4872
+ conv(ctx).search !== undefined,
4873
+ dependsOn: ["search.mode-accepted"],
4874
+ id: "search.modes-differ",
4875
+ needs: "at least two declared searchModes and a mode parameter",
4876
+ async run(ctx) {
4877
+ const modes = resolvedCaps(ctx).searchModes ?? [];
4878
+ const a = modes[0];
4879
+ const b = modes[1];
4880
+ if (a === undefined || b === undefined)
4881
+ return;
4882
+ const field = ctx.query?.searchable[0];
4883
+ const token = field === undefined
4884
+ ? "oat"
4885
+ : ctx.records
4886
+ .map((row) => row[field])
4887
+ .find((value) => typeof value === "string" && value.length > 2);
4888
+ if (token === undefined)
4889
+ return;
4890
+ const left = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: a }));
4891
+ const right = await list(ctx, q(ctx, { limit: 100, search: token, searchMode: b }));
4892
+ if (left.exchange.status >= 400 || right.exchange.status >= 400)
4893
+ return;
4894
+ if (ids(left.items, ctx.identity).join(",") !== ids(right.items, ctx.identity).join(","))
4895
+ return;
4896
+ ctx.findings.unresolved(this.id, ctx.entityName, `modes ${a} and ${b} returned the same set; cannot prove they are distinct implementations`);
4897
+ },
4898
+ };
4899
+ const selectRequestedFieldsPresent = {
4900
+ applicable: (ctx) => conv(ctx).select !== undefined && (ctx.query?.selectable.length ?? 0) > 0,
4901
+ dependsOn: ["select.projection-honoured"],
4902
+ id: "select.requested-fields-present",
4903
+ needs: "a select parameter and at least one selectable field",
4904
+ async run(ctx) {
4905
+ const requested = [
4906
+ ctx.identity,
4907
+ ...(ctx.query?.selectable.filter((name) => name !== ctx.identity).slice(0, 2) ?? []),
4908
+ ];
4909
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
4910
+ if (projection === null)
4911
+ return;
4912
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
4913
+ if (result.exchange.status >= 400 || result.items[0] === undefined) {
4914
+ return ctx.findings.unresolved(this.id, ctx.entityName, "select probe was rejected or empty");
4915
+ }
4916
+ const missing = requested.filter((name) => !Object.hasOwn(result.items[0], name));
4917
+ if (missing.length === 0)
4918
+ return;
4919
+ if (missing.length === 1 &&
4920
+ missing[0] === ctx.identity &&
4921
+ !(ctx.query?.selectable.includes(ctx.identity) ?? false)) {
4922
+ return ctx.findings.unresolved(this.id, ctx.entityName, "identity was omitted from select and missing from items");
4923
+ }
4924
+ ctx.findings.backend(this.id, ctx.entityName, "a requested select field is missing from the projection", `select=${requested.join(",")} omitted ${missing.join(", ")}.`, [result.exchange]);
4925
+ },
4926
+ };
4927
+ const selectUnknownFieldRejected = {
4928
+ applicable: (ctx) => conv(ctx).select !== undefined && resolvedCaps(ctx).select?.unknown !== undefined,
4929
+ dependsOn: ["select.projection-honoured", "error.malformed-filter-not-5xx"],
4930
+ id: "select.unknown-field-rejected",
4931
+ needs: "`select.unknown` declared",
4932
+ async run(ctx) {
4933
+ const policy = resolvedCaps(ctx).select?.unknown;
4934
+ if (policy === undefined)
4935
+ return;
4936
+ const requested = [ctx.identity, "oat_no_such_select_xyz"];
4937
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
4938
+ if (projection === null)
4939
+ return;
4940
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
4941
+ if (policy === "reject") {
4942
+ if (result.exchange.status >= 400 && result.exchange.status < 500)
4943
+ return;
4944
+ 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]);
4945
+ return;
4946
+ }
4947
+ if (result.exchange.status >= 400) {
4948
+ return ctx.findings.unresolved(this.id, ctx.entityName, "unknown select field was rejected under ignore");
4949
+ }
4950
+ const extras = Object.keys(result.items[0] ?? {}).filter((key) => key === "oat_no_such_select_xyz");
4951
+ if (extras.length === 0)
4952
+ return;
4953
+ 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]);
4954
+ },
4955
+ };
4956
+ const selectNestedHonoured = {
4957
+ applicable: (ctx) => conv(ctx).select !== undefined &&
4958
+ resolvedCaps(ctx).select?.nested === true &&
4959
+ (resolvedCaps(ctx).select?.relations?.length ?? 0) > 0,
4960
+ dependsOn: ["select.projection-honoured"],
4961
+ id: "select.nested-honoured",
4962
+ needs: "select.nested and a named relation",
4963
+ async run(ctx) {
4964
+ const relation = resolvedCaps(ctx).select?.relations?.[0];
4965
+ if (relation === undefined || relation.fields[0] === undefined)
4966
+ return;
4967
+ const clause = `${relation.name}(${relation.fields[0]})`;
4968
+ const projection = selectTerm(conv(ctx), [ctx.identity, clause], ctx.entityName);
4969
+ if (projection === null)
4970
+ return;
4971
+ const result = await list(ctx, { ...q(ctx, { limit: 5 }), ...projection });
4972
+ if (result.exchange.status >= 400 || result.items[0] === undefined) {
4973
+ return ctx.findings.unresolved(this.id, ctx.entityName, "nested select was rejected or empty");
4974
+ }
4975
+ const nested = result.items[0][relation.name];
4976
+ if (nested === null || typeof nested !== "object") {
4977
+ 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]);
4978
+ return;
4979
+ }
4980
+ const keys = Object.keys(nested);
4981
+ if (keys.every((key) => key === relation.fields[0]))
4982
+ return;
4983
+ 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]);
4984
+ },
4985
+ };
4986
+ const querySortAndSelectCompose = {
4987
+ applicable: (ctx) => conv(ctx).order !== undefined &&
4988
+ conv(ctx).select !== undefined &&
4989
+ (ctx.query?.sortable.length ?? 0) > 0 &&
4990
+ (ctx.query?.selectable.length ?? 0) > 0 &&
4991
+ ctx.records.length > 1,
4992
+ dependsOn: ["sort.order-is-applied", "select.projection-honoured", "select.requested-fields-present"],
4993
+ id: "query.sort-and-select-compose",
4994
+ needs: "sortable and selectable fields",
4995
+ async run(ctx) {
4996
+ const field = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
4997
+ const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
4998
+ if (field === undefined)
4999
+ return;
5000
+ const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
5001
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
5002
+ if (projection === null)
5003
+ return;
5004
+ const result = await list(ctx, {
5005
+ ...q(ctx, { limit: 20, order: sortTerm(conv(ctx), field, "asc") }),
5006
+ ...projection,
5007
+ });
5008
+ if (result.exchange.status >= 400 || result.items.length < 2) {
5009
+ return ctx.findings.unresolved(this.id, ctx.entityName, "sort+select probe was rejected");
5010
+ }
5011
+ const extras = Object.keys(result.items[0] ?? {}).filter((key) => !requested.includes(key));
5012
+ const values = result.items.map((item) => item[field]);
5013
+ const sorted = [...values].sort(compareValues);
5014
+ if (extras.length === 0 && JSON.stringify(values) === JSON.stringify(sorted))
5015
+ return;
5016
+ ctx.findings.backend(this.id, ctx.entityName, "sort and select do not compose", extras.length > 0
5017
+ ? `select leaked ${extras.slice(0, 5).join(", ")}.`
5018
+ : `order=${field}.asc did not hold under the projection.`, [result.exchange]);
5019
+ },
5020
+ };
5021
+ const querySearchAndSelectCompose = {
5022
+ applicable: (ctx) => conv(ctx).search !== undefined &&
5023
+ conv(ctx).select !== undefined &&
5024
+ (ctx.query?.searchable.length ?? 0) > 0 &&
5025
+ (ctx.query?.selectable.length ?? 0) > 0,
5026
+ dependsOn: ["search.q-narrows-result", "select.projection-honoured"],
5027
+ id: "query.search-and-select-compose",
5028
+ needs: "searchable and selectable fields",
5029
+ async run(ctx) {
5030
+ const field = ctx.query?.searchable[0];
5031
+ if (field === undefined)
5032
+ return;
5033
+ const token = ctx.records
5034
+ .map((row) => row[field])
5035
+ .find((value) => typeof value === "string" && value.length > 2);
5036
+ if (token === undefined)
5037
+ return;
5038
+ const requested = [ctx.identity, field];
5039
+ const projection = selectTerm(conv(ctx), requested, ctx.entityName);
5040
+ if (projection === null)
5041
+ return;
5042
+ const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
5043
+ const combined = await list(ctx, { ...q(ctx, { limit: 100, search: token }), ...projection });
5044
+ if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
5045
+ return ctx.findings.unresolved(this.id, ctx.entityName, "search+select probe was rejected");
5046
+ }
5047
+ if (combined.items.some((item) => item[ctx.identity] === undefined)) {
5048
+ return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
5049
+ }
5050
+ if (sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity)))
5051
+ return;
5052
+ 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]);
5053
+ },
5054
+ };
5055
+ const querySearchAndSortCompose = {
5056
+ applicable: (ctx) => conv(ctx).search !== undefined &&
5057
+ conv(ctx).order !== undefined &&
5058
+ (ctx.query?.searchable.length ?? 0) > 0 &&
5059
+ (ctx.query?.sortable.length ?? 0) > 0,
5060
+ dependsOn: ["search.q-narrows-result", "sort.order-is-applied"],
5061
+ id: "query.search-and-sort-compose",
5062
+ needs: "searchable and sortable fields",
5063
+ async run(ctx) {
5064
+ const searchField = ctx.query?.searchable[0];
5065
+ const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
5066
+ if (searchField === undefined || sortField === undefined)
5067
+ return;
5068
+ const token = ctx.records
5069
+ .map((row) => row[searchField])
5070
+ .find((value) => typeof value === "string" && value.length > 2);
5071
+ if (token === undefined)
5072
+ return;
5073
+ const searched = await list(ctx, q(ctx, { limit: 100, search: token }));
5074
+ const combined = await list(ctx, q(ctx, { limit: 100, search: token, order: sortTerm(conv(ctx), sortField, "asc") }));
5075
+ if (searched.exchange.status >= 400 || combined.exchange.status >= 400) {
5076
+ return ctx.findings.unresolved(this.id, ctx.entityName, "search+sort probe was rejected");
5077
+ }
5078
+ if (!sameSet(setOf(searched.items, ctx.identity), setOf(combined.items, ctx.identity))) {
5079
+ 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]);
5080
+ return;
5081
+ }
5082
+ const values = combined.items.map((item) => item[sortField]);
5083
+ const sorted = [...values].sort(compareValues);
5084
+ if (JSON.stringify(values) === JSON.stringify(sorted))
5085
+ return;
5086
+ ctx.findings.backend(this.id, ctx.entityName, "search results are not ordered", `q + order=${sortField}.asc did not keep ${sortField} ascending.`, [combined.exchange]);
5087
+ },
5088
+ };
5089
+ const queryFilterSearchSortSelectCompose = {
5090
+ applicable: (ctx) => filterable(ctx) &&
5091
+ conv(ctx).search !== undefined &&
5092
+ conv(ctx).order !== undefined &&
5093
+ conv(ctx).select !== undefined &&
5094
+ (ctx.query?.searchable.length ?? 0) > 0 &&
5095
+ (ctx.query?.sortable.length ?? 0) > 0 &&
5096
+ (ctx.query?.selectable.length ?? 0) > 0 &&
5097
+ ctx.records.length > 2,
5098
+ dependsOn: [
5099
+ "query.search-and-filter-compose",
5100
+ "query.axes-compose",
5101
+ "query.filter-and-select-compose",
5102
+ "query.sort-and-select-compose",
5103
+ "query.filter-sort-select-compose",
5104
+ "query.filter-search-sort-compose",
5105
+ "query.filter-search-select-compose",
5106
+ "pagination.page-walk-covers-set",
5107
+ ],
5108
+ id: "query.filter-search-sort-select-compose",
5109
+ needs: "all four list axes declared and at least three records",
5110
+ async run(ctx) {
5111
+ const picked = overlappingFilterAndSearch(ctx);
5112
+ if (picked === null) {
5113
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no overlapping filter/search token");
5114
+ }
5115
+ const sortField = ctx.query?.sortable.find((name) => name !== ctx.identity) ?? ctx.query?.sortable[0];
5116
+ const extra = ctx.query?.selectable.find((name) => name !== ctx.identity);
5117
+ if (sortField === undefined)
5118
+ return;
5119
+ const conventions = conv(ctx);
5120
+ const term = filterTerm(conventions, picked.field, "eq", String(picked.target[picked.field]));
5121
+ if (term === null)
5122
+ return;
5123
+ const requested = extra === undefined ? [ctx.identity] : [ctx.identity, extra];
5124
+ const projection = selectTerm(conventions, requested, ctx.entityName);
5125
+ if (projection === null)
5126
+ return;
5127
+ const base = await collectSet(ctx, ctx.query?.maxLimit ?? 100, {
5128
+ ...term,
5129
+ ...(conventions.search === undefined ? {} : { [conventions.search]: picked.token }),
5130
+ });
5131
+ 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"));
5132
+ if (base === null || combined === null) {
5133
+ return ctx.findings.unresolved(this.id, ctx.entityName, "four-axis probe was rejected");
5134
+ }
5135
+ if (combined.items.some((item) => item[ctx.identity] === undefined)) {
5136
+ return ctx.findings.unresolved(this.id, ctx.entityName, "projection omitted the identity");
5137
+ }
5138
+ if (sameSet(setOf(base.items, ctx.identity), setOf(combined.items, ctx.identity)))
5139
+ return;
5140
+ 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]);
5141
+ },
5142
+ };
3784
5143
  export const CHECKS = [
3785
5144
  /* foundations — did the write land, is it visible, and do the paging primitives work at all.
3786
5145
  * Everything below assumes these hold, so they must be evaluated first for cascade
@@ -3798,23 +5157,47 @@ export const CHECKS = [
3798
5157
  * compare. Establishing paging first is what lets those failures be suppressed as cascades
3799
5158
  * rather than re-reported once per predicate. */
3800
5159
  orderChangesResult,
5160
+ sortNumericOrderIsNumeric,
5161
+ sortNullsFirstLast,
5162
+ sortMultiKeyTiebreak,
5163
+ sortDefaultOrderApplied,
5164
+ sortStableTiebreak,
3801
5165
  pageWalkCoversSet,
3802
5166
  cursorAgreesWithPage,
3803
5167
  /* query semantics */
3804
5168
  unknownFilterRejected,
3805
5169
  malformedFilterNot5xx,
5170
+ sortUnknownFieldRejected,
3806
5171
  equalityFilterSelectsOne,
3807
5172
  zeroMatchFilter,
3808
5173
  negationPartitions,
3809
5174
  filterAndComposesAsIntersection,
3810
5175
  filterOrComposesAsUnion,
5176
+ filterInIsUnionOfEq,
5177
+ filterNinComplementsIn,
5178
+ filterNestedAndOrDistributes,
3811
5179
  likeEscaping,
5180
+ filterIlikeIsCaseInsensitive,
5181
+ filterIsNullSelectsNulls,
5182
+ filterContainsMembership,
3812
5183
  sortReverseSymmetry,
3813
5184
  searchNarrowsResult,
5185
+ searchTokensAnd,
5186
+ searchCaseInsensitive,
5187
+ searchEmptyQ,
5188
+ searchUndeclaredFieldNotRequired,
5189
+ searchModeAccepted,
5190
+ searchModesDiffer,
3814
5191
  selectProjection,
5192
+ selectRequestedFieldsPresent,
5193
+ selectUnknownFieldRejected,
5194
+ selectNestedHonoured,
3815
5195
  countIsConsistentWithPage,
3816
5196
  countMatchesWalk,
3817
5197
  numericComparisonIsNumeric,
5198
+ filterGteIsGtOrEq,
5199
+ filterLteIsLtOrEq,
5200
+ filterOrderedTriplePartitions,
3818
5201
  /* write semantics */
3819
5202
  patchMinimality,
3820
5203
  immutableRejected,
@@ -3829,9 +5212,21 @@ export const CHECKS = [
3829
5212
  filterSortSelectCompose,
3830
5213
  filterSearchSortCompose,
3831
5214
  filterSearchSelectCompose,
5215
+ querySortAndSelectCompose,
5216
+ querySearchAndSelectCompose,
5217
+ querySearchAndSortCompose,
5218
+ queryFilterSearchSortSelectCompose,
3832
5219
  declaredFilterableWorks,
5220
+ declaredFilterableOpsAccepted,
5221
+ declaredFilterableIllegalOpRejected,
3833
5222
  declaredSortableWorks,
5223
+ declaredSortableNullsAccepted,
3834
5224
  declaredSelectableWorks,
5225
+ filterAliasMatchesCanonical,
5226
+ filterIllegalOpRejected,
5227
+ filterEmptyIn,
5228
+ filterInOverLimitRejected,
5229
+ filterConditionCapRejected,
3835
5230
  noLostUpdate,
3836
5231
  deleteMissingIs404,
3837
5232
  softDeleteHidden,
@@ -3849,6 +5244,7 @@ export const CHECKS = [
3849
5244
  /* declared side effects and async lifecycles, last: both invoke operations that change the
3850
5245
  * world, and both are meaningless if the read surface above is already known broken */
3851
5246
  declaredEffectsOccur,
5247
+ sideEffectArrives,
3852
5248
  asyncReachesTerminalState,
3853
5249
  asyncReceiptIsResolvable,
3854
5250
  /* After every other check has written to the transcript — create is owned by