@lovrozagar/oat 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +1316 -176
  2. package/dist/cli.js +18 -16
  3. package/dist/config/define-config.d.ts +50 -0
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js.map +1 -1
  6. package/dist/config/load.d.ts +1 -1
  7. package/dist/config/load.d.ts.map +1 -1
  8. package/dist/config/load.js.map +1 -1
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +1 -1
  11. package/dist/reference/defects.d.ts.map +1 -1
  12. package/dist/reference/defects.js +4 -1
  13. package/dist/reference/defects.js.map +1 -1
  14. package/dist/reference/dialect.d.ts.map +1 -1
  15. package/dist/reference/dialect.js +4 -1
  16. package/dist/reference/dialect.js.map +1 -1
  17. package/dist/reference/http.d.ts.map +1 -1
  18. package/dist/reference/http.js +15 -22
  19. package/dist/reference/http.js.map +1 -1
  20. package/dist/reference/model.d.ts.map +1 -1
  21. package/dist/reference/model.js +27 -3
  22. package/dist/reference/model.js.map +1 -1
  23. package/dist/reference/query.d.ts.map +1 -1
  24. package/dist/reference/query.js +19 -35
  25. package/dist/reference/query.js.map +1 -1
  26. package/dist/reference/spec.d.ts.map +1 -1
  27. package/dist/reference/spec.js +8 -6
  28. package/dist/reference/spec.js.map +1 -1
  29. package/dist/reference/store-api.d.ts.map +1 -1
  30. package/dist/reference/store-api.js +4 -1
  31. package/dist/reference/store-api.js.map +1 -1
  32. package/dist/reference/stores/memory.d.ts.map +1 -1
  33. package/dist/reference/stores/memory.js +1 -3
  34. package/dist/reference/stores/memory.js.map +1 -1
  35. package/dist/reference/stores/postgres.d.ts.map +1 -1
  36. package/dist/reference/stores/postgres.js +8 -8
  37. package/dist/reference/stores/postgres.js.map +1 -1
  38. package/dist/reference/stores/sqlite-driver.d.ts.map +1 -1
  39. package/dist/reference/stores/sqlite-driver.js +2 -4
  40. package/dist/reference/stores/sqlite-driver.js.map +1 -1
  41. package/dist/reference/stores/sqlite.d.ts.map +1 -1
  42. package/dist/reference/stores/sqlite.js +22 -27
  43. package/dist/reference/stores/sqlite.js.map +1 -1
  44. package/dist/report/console.d.ts.map +1 -1
  45. package/dist/report/console.js +4 -4
  46. package/dist/report/console.js.map +1 -1
  47. package/dist/report/matrix.d.ts +6 -5
  48. package/dist/report/matrix.d.ts.map +1 -1
  49. package/dist/report/matrix.js +82 -52
  50. package/dist/report/matrix.js.map +1 -1
  51. package/dist/report/render.d.ts +8 -0
  52. package/dist/report/render.d.ts.map +1 -1
  53. package/dist/report/render.js +40 -20
  54. package/dist/report/render.js.map +1 -1
  55. package/dist/runtime/async.d.ts.map +1 -1
  56. package/dist/runtime/async.js +4 -1
  57. package/dist/runtime/async.js.map +1 -1
  58. package/dist/runtime/auth.d.ts.map +1 -1
  59. package/dist/runtime/auth.js +12 -16
  60. package/dist/runtime/auth.js.map +1 -1
  61. package/dist/runtime/checks.d.ts.map +1 -1
  62. package/dist/runtime/checks.js +195 -218
  63. package/dist/runtime/checks.js.map +1 -1
  64. package/dist/runtime/client.d.ts +12 -1
  65. package/dist/runtime/client.d.ts.map +1 -1
  66. package/dist/runtime/client.js +17 -2
  67. package/dist/runtime/client.js.map +1 -1
  68. package/dist/runtime/finding.d.ts.map +1 -1
  69. package/dist/runtime/finding.js.map +1 -1
  70. package/dist/runtime/fixture.d.ts.map +1 -1
  71. package/dist/runtime/fixture.js.map +1 -1
  72. package/dist/runtime/profile.d.ts +30 -0
  73. package/dist/runtime/profile.d.ts.map +1 -0
  74. package/dist/runtime/profile.js +46 -0
  75. package/dist/runtime/profile.js.map +1 -0
  76. package/dist/runtime/progress.js +4 -4
  77. package/dist/runtime/progress.js.map +1 -1
  78. package/dist/runtime/rate-limit.d.ts +53 -0
  79. package/dist/runtime/rate-limit.d.ts.map +1 -0
  80. package/dist/runtime/rate-limit.js +165 -0
  81. package/dist/runtime/rate-limit.js.map +1 -0
  82. package/dist/runtime/run.d.ts +15 -1
  83. package/dist/runtime/run.d.ts.map +1 -1
  84. package/dist/runtime/run.js +137 -51
  85. package/dist/runtime/run.js.map +1 -1
  86. package/dist/runtime/validate.d.ts.map +1 -1
  87. package/dist/runtime/validate.js.map +1 -1
  88. package/dist/runtime/world.d.ts.map +1 -1
  89. package/dist/runtime/world.js.map +1 -1
  90. package/dist/spec/collection.d.ts.map +1 -1
  91. package/dist/spec/collection.js +2 -5
  92. package/dist/spec/collection.js.map +1 -1
  93. package/dist/spec/conventions.d.ts.map +1 -1
  94. package/dist/spec/conventions.js +2 -2
  95. package/dist/spec/conventions.js.map +1 -1
  96. package/dist/spec/extensions.d.ts +11 -0
  97. package/dist/spec/extensions.d.ts.map +1 -1
  98. package/dist/spec/extensions.js +17 -6
  99. package/dist/spec/extensions.js.map +1 -1
  100. package/dist/spec/graph.d.ts +2 -1
  101. package/dist/spec/graph.d.ts.map +1 -1
  102. package/dist/spec/graph.js +4 -4
  103. package/dist/spec/graph.js.map +1 -1
  104. package/dist/spec/load.d.ts.map +1 -1
  105. package/dist/spec/load.js +1 -3
  106. package/dist/spec/load.js.map +1 -1
  107. package/labs/README.md +63 -63
  108. package/labs/annotated-openapi.yaml +5 -5
  109. package/labs/demo.mjs +2 -10
  110. package/labs/{anyrow.config.ts → oob-auth.config.ts} +9 -9
  111. package/package.json +89 -78
@@ -389,7 +389,9 @@ const readAfterWrite = {
389
389
  const evidence = located.last === null ? [] : [located.last.exchange];
390
390
  let detail = `created ${ctx.entityName} ${id} is absent from the list projection`;
391
391
  if (ctx.readOp !== undefined) {
392
- const item = await ctx.client.get(fillPath(ctx.readOp.path, { ...ctx.scope, ...itemParamFor(ctx, id) }), { headers: ctx.auth() });
392
+ const item = await ctx.client.get(fillPath(ctx.readOp.path, { ...ctx.scope, ...itemParamFor(ctx, id) }), {
393
+ headers: ctx.auth(),
394
+ });
393
395
  evidence.push(item);
394
396
  if (item.status < 300) {
395
397
  detail =
@@ -425,8 +427,7 @@ const unknownFilterRejected = {
425
427
  return;
426
428
  const result = await list(ctx, { ...q(ctx, { limit: 100 }), ...unknownField });
427
429
  if (result.exchange.status >= 500) {
428
- ctx.findings.backend("error.malformed-filter-not-5xx", ctx.entityName, "unknown filter field produces a server error", `filtering on an undeclared field returned ${result.exchange.status}; a rejected input ` +
429
- "should be a 4xx", [result.exchange]);
430
+ ctx.findings.backend("error.malformed-filter-not-5xx", ctx.entityName, "unknown filter field produces a server error", `filtering on an undeclared field returned ${result.exchange.status}; a rejected input ` + "should be a 4xx", [result.exchange]);
430
431
  return;
431
432
  }
432
433
  if (result.exchange.status >= 400)
@@ -440,9 +441,7 @@ const unknownFilterRejected = {
440
441
  },
441
442
  };
442
443
  const equalityFilterSelectsOne = {
443
- applicable: (ctx) => filterable(ctx) &&
444
- ctx.records.length > 0 &&
445
- (ctx.query?.filterable.includes(ctx.identity) ?? false),
444
+ applicable: (ctx) => filterable(ctx) && ctx.records.length > 0 && (ctx.query?.filterable.includes(ctx.identity) ?? false),
446
445
  dependsOn: ["list.read-after-write"],
447
446
  id: "filter.equality-selects-exactly-one",
448
447
  needs: "a `filter` parameter that accepts the identity field",
@@ -467,8 +466,7 @@ const equalityFilterSelectsOne = {
467
466
  },
468
467
  };
469
468
  const zeroMatchFilter = {
470
- applicable: (ctx) => filterable(ctx) &&
471
- (ctx.query?.filterable.includes(ctx.identity) ?? false),
469
+ applicable: (ctx) => filterable(ctx) && (ctx.query?.filterable.includes(ctx.identity) ?? false),
472
470
  dependsOn: ["list.read-after-write"],
473
471
  id: "filter.zero-match-returns-none",
474
472
  needs: "a `filter` parameter that accepts the identity field",
@@ -486,9 +484,7 @@ const zeroMatchFilter = {
486
484
  },
487
485
  };
488
486
  const negationPartitions = {
489
- applicable: (ctx) => filterable(ctx) &&
490
- ctx.records.length > 1 &&
491
- (ctx.query?.filterable.includes(ctx.identity) ?? false),
487
+ applicable: (ctx) => filterable(ctx) && ctx.records.length > 1 && (ctx.query?.filterable.includes(ctx.identity) ?? false),
492
488
  dependsOn: [
493
489
  "list.read-after-write",
494
490
  /* Partitioning is asserted over field *values*. A backend that drops submitted fields
@@ -529,13 +525,9 @@ const negationPartitions = {
529
525
  /* Set algebra only holds over complete sets. A walk that hit the page cap has seen a
530
526
  * prefix, and the union would legitimately miss whatever lies beyond it. */
531
527
  if (!all.complete || !matching.complete || !complement.complete) {
532
- return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the union cannot be compared "
533
- + "against the whole set");
528
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the union cannot be compared " + "against the whole set");
534
529
  }
535
- const union = new Set([
536
- ...ids(matching.items, ctx.identity),
537
- ...ids(complement.items, ctx.identity),
538
- ]);
530
+ const union = new Set([...ids(matching.items, ctx.identity), ...ids(complement.items, ctx.identity)]);
539
531
  const expected = new Set(ids(all.items, ctx.identity));
540
532
  const overlap = ids(matching.items, ctx.identity).filter((value) => ids(complement.items, ctx.identity).includes(value));
541
533
  if (overlap.length > 0) {
@@ -717,8 +709,8 @@ const sortReverseSymmetry = {
717
709
  * return opposite *windows* of it — legitimately different sets, and comparing them would
718
710
  * report every capped collection as broken. */
719
711
  if (!ascending.complete || !descending.complete) {
720
- return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the two directions return "
721
- + "different windows of it rather than the same set reversed");
712
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the collection is larger than the walk covers, so the two directions return " +
713
+ "different windows of it rather than the same set reversed");
722
714
  }
723
715
  const forward = ids(ascending.items, ctx.identity);
724
716
  const backward = ids(descending.items, ctx.identity);
@@ -956,9 +948,7 @@ const patchMinimality = {
956
948
  * and repeated sampling cannot distinguish those from a write side effect once they
957
949
  * settle. A field that moves on the control moved on its own. */
958
950
  const control = ctx.records.find((r) => String(r[ctx.identity]) !== id);
959
- const controlParams = control === undefined
960
- ? undefined
961
- : { ...ctx.scope, ...itemParamFor(ctx, String(control[ctx.identity])) };
951
+ const controlParams = control === undefined ? undefined : { ...ctx.scope, ...itemParamFor(ctx, String(control[ctx.identity])) };
962
952
  const controlBefore = controlParams === undefined
963
953
  ? undefined
964
954
  : await ctx.client.get(fillPath(ctx.readOp.path, controlParams), { headers: ctx.auth() });
@@ -1047,7 +1037,10 @@ function pickWritableStringField(ctx, record) {
1047
1037
  return null;
1048
1038
  }
1049
1039
  const immutableRejected = {
1050
- applicable: (ctx) => ctx.updateOp !== undefined && ctx.readOp !== undefined && (ctx.updateOp.immutable.length > 0) && ctx.records.length > 0,
1040
+ applicable: (ctx) => ctx.updateOp !== undefined &&
1041
+ ctx.readOp !== undefined &&
1042
+ ctx.updateOp.immutable.length > 0 &&
1043
+ ctx.records.length > 0,
1051
1044
  mutates: true,
1052
1045
  id: "patch.immutable-field-rejected",
1053
1046
  needs: "fields declared immutable via x-immutable",
@@ -1237,7 +1230,9 @@ const denialDoesNotRevealExistence = {
1237
1230
  /* Shaped like a real identifier so the difference under test is existence, not format —
1238
1231
  * a backend may legitimately reject a malformed id differently. */
1239
1232
  const absentId = `${realId}-oat-absent`;
1240
- const ask = async (id) => ctx.client.get(fillPath(ctx.readOp?.path ?? "", { ...(ctx.altScope ?? ctx.scope), ...itemParamFor(ctx, id) }), { headers: ctx.altAuth?.() ?? {} });
1233
+ const ask = async (id) => ctx.client.get(fillPath(ctx.readOp?.path ?? "", { ...(ctx.altScope ?? ctx.scope), ...itemParamFor(ctx, id) }), {
1234
+ headers: ctx.altAuth?.() ?? {},
1235
+ });
1241
1236
  const existing = await ask(realId);
1242
1237
  const absent = await ask(absentId);
1243
1238
  /* Only a denial pair is meaningful. A 2xx on the existing record is a cross-tenant read,
@@ -1247,10 +1242,10 @@ const denialDoesNotRevealExistence = {
1247
1242
  }
1248
1243
  if (existing.status === absent.status)
1249
1244
  return;
1250
- ctx.findings.security(this.id, ctx.entityName, "the denial status reveals whether a record exists", `a record belonging to another tenant was refused with ${existing.status}, while an id `
1251
- + `that does not exist was refused with ${absent.status}. The difference is an oracle: `
1252
- + "anyone able to guess or enumerate identifiers can learn which ones are real without "
1253
- + "ever reading one. Both cases must answer identically — conventionally 404.", [existing, absent]);
1245
+ ctx.findings.security(this.id, ctx.entityName, "the denial status reveals whether a record exists", `a record belonging to another tenant was refused with ${existing.status}, while an id ` +
1246
+ `that does not exist was refused with ${absent.status}. The difference is an oracle: ` +
1247
+ "anyone able to guess or enumerate identifiers can learn which ones are real without " +
1248
+ "ever reading one. Both cases must answer identically — conventionally 404.", [existing, absent]);
1254
1249
  },
1255
1250
  };
1256
1251
  /**
@@ -1296,9 +1291,9 @@ const idempotentReplay = {
1296
1291
  const secondId = String((second.responseBody ?? {})[ctx.identity]);
1297
1292
  if (firstId === secondId)
1298
1293
  return;
1299
- ctx.findings.backend(this.id, ctx.entityName, "replaying a request with the same idempotency key created a second record", `two identical POSTs carrying ${header}: "${key}" produced ${ctx.entityName} ${firstId} `
1300
- + `and ${secondId}. The header is declared, so clients will retry on timeouts assuming `
1301
- + "it protects them; every such retry silently duplicates the record.", [first, second]);
1294
+ ctx.findings.backend(this.id, ctx.entityName, "replaying a request with the same idempotency key created a second record", `two identical POSTs carrying ${header}: "${key}" produced ${ctx.entityName} ${firstId} ` +
1295
+ `and ${secondId}. The header is declared, so clients will retry on timeouts assuming ` +
1296
+ "it protects them; every such retry silently duplicates the record.", [first, second]);
1302
1297
  },
1303
1298
  };
1304
1299
  /**
@@ -1315,8 +1310,8 @@ const idempotentReplay = {
1315
1310
  * covered by read-after-write, and re-asserting it here would report one defect twice.
1316
1311
  */
1317
1312
  const declaredInvalidationHappens = {
1318
- applicable: (ctx) => ctx.createOp !== undefined
1319
- && ctx.createOp.invalidates.some((route) => ctx.model.byRoute.get(route)?.entity !== ctx.entityName),
1313
+ applicable: (ctx) => ctx.createOp !== undefined &&
1314
+ ctx.createOp.invalidates.some((route) => ctx.model.byRoute.get(route)?.entity !== ctx.entityName),
1320
1315
  dependsOn: ["list.read-after-write", "create.persists-submitted-fields"],
1321
1316
  id: "invalidation.declared-route-changes",
1322
1317
  mutates: true,
@@ -1372,11 +1367,11 @@ const declaredInvalidationHappens = {
1372
1367
  continue;
1373
1368
  if (JSON.stringify(after.responseBody) !== snapshot)
1374
1369
  continue;
1375
- ctx.findings.backend(this.id, ctx.entityName, "a route the document says is invalidated by this write did not change", `creating a ${ctx.entityName} declares x-invalidate on "${probe.op.route}", but that `
1376
- + "route returned a byte-identical body before and after the write. Either it serves "
1377
- + "a value derived from this entity and that value is stale — a denormalised counter "
1378
- + "or a cached projection nobody refreshed — or the declaration is wrong and every "
1379
- + "client following it is invalidating the wrong cache key.", [created, after]);
1370
+ ctx.findings.backend(this.id, ctx.entityName, "a route the document says is invalidated by this write did not change", `creating a ${ctx.entityName} declares x-invalidate on "${probe.op.route}", but that ` +
1371
+ "route returned a byte-identical body before and after the write. Either it serves " +
1372
+ "a value derived from this entity and that value is stale — a denormalised counter " +
1373
+ "or a cached projection nobody refreshed — or the declaration is wrong and every " +
1374
+ "client following it is invalidating the wrong cache key.", [created, after]);
1380
1375
  return;
1381
1376
  }
1382
1377
  },
@@ -1426,24 +1421,23 @@ const projectionsAgree = {
1426
1421
  * mid-check for reasons no read path is responsible for. Comparing those across
1427
1422
  * projections measures timing, not consistency.
1428
1423
  */
1429
- const generated = new Set([
1430
- ...(ctx.createOp?.generated ?? []),
1431
- ...(ctx.updateOp?.generated ?? []),
1432
- ]);
1433
- const field = (ctx.query?.filterable ?? []).find((name) => name !== ctx.identity
1434
- && !generated.has(name)
1435
- && (ctx.query?.sortable ?? []).includes(name)
1436
- && target[name] !== null
1437
- && target[name] !== undefined
1438
- && typeof target[name] !== "object");
1424
+ const generated = new Set([...(ctx.createOp?.generated ?? []), ...(ctx.updateOp?.generated ?? [])]);
1425
+ const field = (ctx.query?.filterable ?? []).find((name) => name !== ctx.identity &&
1426
+ !generated.has(name) &&
1427
+ (ctx.query?.sortable ?? []).includes(name) &&
1428
+ target[name] !== null &&
1429
+ target[name] !== undefined &&
1430
+ typeof target[name] !== "object");
1439
1431
  if (field === undefined) {
1440
- return ctx.findings.unresolved(this.id, ctx.entityName, "no field is filterable, sortable and non-null on the sample record, so no single fact "
1441
- + "can be traced through every projection");
1432
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no field is filterable, sortable and non-null on the sample record, so no single fact " +
1433
+ "can be traced through every projection");
1442
1434
  }
1443
1435
  const limit = ctx.query?.maxLimit ?? 100;
1444
1436
  const seen = [];
1445
1437
  const record = (result) => result.items.find((item) => String(item[ctx.identity]) === id);
1446
- const detail = await ctx.client.get(fillPath(ctx.readOp.path, { ...ctx.scope, ...itemParamFor(ctx, id) }), { headers: ctx.auth() });
1438
+ const detail = await ctx.client.get(fillPath(ctx.readOp.path, { ...ctx.scope, ...itemParamFor(ctx, id) }), {
1439
+ headers: ctx.auth(),
1440
+ });
1447
1441
  if (detail.status >= 300) {
1448
1442
  return ctx.findings.unresolved(this.id, ctx.entityName, `the item route returned ${detail.status}, leaving no reference value to compare against`);
1449
1443
  }
@@ -1476,11 +1470,11 @@ const projectionsAgree = {
1476
1470
  }
1477
1471
  const disagreeing = seen.filter((entry) => JSON.stringify(entry.value) !== JSON.stringify(value));
1478
1472
  if (disagreeing.length > 0) {
1479
- ctx.findings.backend(this.id, ctx.entityName, "the same record carries different values depending on how it is read", `${ctx.entityName} ${id} has "${field}" = ${JSON.stringify(value)} on the record oat `
1480
- + `created, but ${disagreeing
1473
+ ctx.findings.backend(this.id, ctx.entityName, "the same record carries different values depending on how it is read", `${ctx.entityName} ${id} has "${field}" = ${JSON.stringify(value)} on the record oat ` +
1474
+ `created, but ${disagreeing
1481
1475
  .map((entry) => `the ${entry.projection} returns ${JSON.stringify(entry.value)}`)
1482
- .join(", and ")}. At least one read path is serving something the others are not; `
1483
- + "a client's view of a record then depends on which route it happened to use.", [detail, plain.exchange]);
1476
+ .join(", and ")}. At least one read path is serving something the others are not; ` +
1477
+ "a client's view of a record then depends on which route it happened to use.", [detail, plain.exchange]);
1484
1478
  return;
1485
1479
  }
1486
1480
  /* Membership is a projection too: a filter that matches the value must return the record,
@@ -1493,9 +1487,9 @@ const projectionsAgree = {
1493
1487
  return;
1494
1488
  if (record(included) !== undefined)
1495
1489
  return;
1496
- ctx.findings.backend(this.id, ctx.entityName, "a record is missing from a filter matching its own field value", `${ctx.entityName} ${id} carries "${field}" = ${JSON.stringify(value)} on every read path, `
1497
- + `yet filtering for exactly that value does not return it. The record and the predicate `
1498
- + "agree; the index or query that answers the filter does not.", [detail, included.exchange]);
1490
+ ctx.findings.backend(this.id, ctx.entityName, "a record is missing from a filter matching its own field value", `${ctx.entityName} ${id} carries "${field}" = ${JSON.stringify(value)} on every read path, ` +
1491
+ `yet filtering for exactly that value does not return it. The record and the predicate ` +
1492
+ "agree; the index or query that answers the filter does not.", [detail, included.exchange]);
1499
1493
  },
1500
1494
  };
1501
1495
  /**
@@ -1512,10 +1506,7 @@ const projectionsAgree = {
1512
1506
  * A sort is not a predicate, and it must not behave as one.
1513
1507
  */
1514
1508
  const queryAxesCompose = {
1515
- applicable: (ctx) => filterable(ctx)
1516
- && conv(ctx).order !== undefined
1517
- && ctx.records.length > 2
1518
- && (ctx.query?.sortable.length ?? 0) > 0,
1509
+ applicable: (ctx) => filterable(ctx) && conv(ctx).order !== undefined && ctx.records.length > 2 && (ctx.query?.sortable.length ?? 0) > 0,
1519
1510
  dependsOn: [
1520
1511
  "list.read-after-write",
1521
1512
  /* Each axis has to work alone before "they disagree when combined" means anything. */
@@ -1556,11 +1547,10 @@ const queryAxesCompose = {
1556
1547
  }
1557
1548
  }
1558
1549
  if (field === undefined || target === undefined) {
1559
- return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them, so "
1560
- + "no filter can select a proper subset to reorder");
1550
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them, so " +
1551
+ "no filter can select a proper subset to reorder");
1561
1552
  }
1562
- const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity)
1563
- ?? ctx.identity;
1553
+ const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity) ?? ctx.identity;
1564
1554
  const term = filterTerm(conventions, field, "eq", String(target[field]));
1565
1555
  if (term === null) {
1566
1556
  return ctx.findings.unresolved(this.id, ctx.entityName, `this API's filter grammar cannot express equality on "${field}"`);
@@ -1576,8 +1566,7 @@ const queryAxesCompose = {
1576
1566
  return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter with a sort was rejected, so the two cannot be compared");
1577
1567
  }
1578
1568
  if (!filtered.complete || !both.complete) {
1579
- return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered set is larger than the walk covers, so the two runs cannot be compared "
1580
- + "as whole sets");
1569
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered set is larger than the walk covers, so the two runs cannot be compared " + "as whole sets");
1581
1570
  }
1582
1571
  const alone = new Set(ids(filtered.items, ctx.identity));
1583
1572
  const combined = new Set(ids(both.items, ctx.identity));
@@ -1585,12 +1574,12 @@ const queryAxesCompose = {
1585
1574
  const extra = [...combined].filter((id) => !alone.has(id));
1586
1575
  if (missing.length === 0 && extra.length === 0)
1587
1576
  return;
1588
- ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a filter returns", `filter on "${field}" alone matched ${alone.size} record(s); the same filter with `
1589
- + `order=${sortField} desc matched ${combined.size}. `
1590
- + (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
1591
- + (extra.length > 0 ? `Appeared: ${extra.slice(0, 3).join(", ")}. ` : "")
1592
- + "Ordering must reorder a result, never change its membership — a filter that only "
1593
- + "holds while unsorted is one an index or query plan is silently dropping.", [filtered.last.exchange, both.last.exchange]);
1577
+ ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a filter returns", `filter on "${field}" alone matched ${alone.size} record(s); the same filter with ` +
1578
+ `order=${sortField} desc matched ${combined.size}. ` +
1579
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "") +
1580
+ (extra.length > 0 ? `Appeared: ${extra.slice(0, 3).join(", ")}. ` : "") +
1581
+ "Ordering must reorder a result, never change its membership — a filter that only " +
1582
+ "holds while unsorted is one an index or query plan is silently dropping.", [filtered.last.exchange, both.last.exchange]);
1594
1583
  },
1595
1584
  };
1596
1585
  /**
@@ -1679,12 +1668,12 @@ const filterAndPagingCompose = {
1679
1668
  const skipped = [...clientSide].filter((id) => !returned.has(id));
1680
1669
  if (skipped.length === 0)
1681
1670
  return;
1682
- ctx.findings.backend(this.id, ctx.entityName, "paging a filtered query skips records that match the filter", `walking the collection at ${pageSize} per page and filtering client-side on `
1683
- + `"${field}" = ${JSON.stringify(value)} finds ${clientSide.size} record(s); asking the `
1684
- + `backend for the same filter returns ${returned.size}. Never returned: `
1685
- + `${skipped.slice(0, 3).join(", ")}. The page window is being computed before the `
1686
- + "predicate is applied, so matching records fall outside it and are lost — page one "
1687
- + "looks correct and later pages silently omit data.", [everything.last.exchange, serverSide.last.exchange]);
1671
+ ctx.findings.backend(this.id, ctx.entityName, "paging a filtered query skips records that match the filter", `walking the collection at ${pageSize} per page and filtering client-side on ` +
1672
+ `"${field}" = ${JSON.stringify(value)} finds ${clientSide.size} record(s); asking the ` +
1673
+ `backend for the same filter returns ${returned.size}. Never returned: ` +
1674
+ `${skipped.slice(0, 3).join(", ")}. The page window is being computed before the ` +
1675
+ "predicate is applied, so matching records fall outside it and are lost — page one " +
1676
+ "looks correct and later pages silently omit data.", [everything.last.exchange, serverSide.last.exchange]);
1688
1677
  },
1689
1678
  };
1690
1679
  /**
@@ -1701,9 +1690,7 @@ const filterAndPagingCompose = {
1701
1690
  * so the two sides remain comparable.
1702
1691
  */
1703
1692
  const filterAndSelectCompose = {
1704
- applicable: (ctx) => filterable(ctx)
1705
- && conv(ctx).select !== undefined
1706
- && ctx.records.length > 2,
1693
+ applicable: (ctx) => filterable(ctx) && conv(ctx).select !== undefined && ctx.records.length > 2,
1707
1694
  dependsOn: [
1708
1695
  "list.read-after-write",
1709
1696
  "create.persists-submitted-fields",
@@ -1738,11 +1725,10 @@ const filterAndSelectCompose = {
1738
1725
  }
1739
1726
  }
1740
1727
  if (field === undefined || target === undefined) {
1741
- return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them, so "
1742
- + "no filter can select a proper subset to project");
1728
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field holds a value shared by several records but not all of them, so " +
1729
+ "no filter can select a proper subset to project");
1743
1730
  }
1744
- const extra = (ctx.query?.selectable ?? []).find((name) => name !== ctx.identity)
1745
- ?? ctx.identity;
1731
+ const extra = (ctx.query?.selectable ?? []).find((name) => name !== ctx.identity) ?? ctx.identity;
1746
1732
  const projection = selectTerm(conventions, [ctx.identity, extra], ctx.entityName);
1747
1733
  if (projection === null) {
1748
1734
  return ctx.findings.unresolved(this.id, ctx.entityName, "this API's select grammar cannot express a sparse fieldset");
@@ -1757,8 +1743,7 @@ const filterAndSelectCompose = {
1757
1743
  return ctx.findings.unresolved(this.id, ctx.entityName, "combining a filter with a select was rejected, so the two cannot be compared");
1758
1744
  }
1759
1745
  if (!filtered.complete || !both.complete) {
1760
- return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered set is larger than the walk covers, so the two runs cannot be compared "
1761
- + "as whole sets");
1746
+ return ctx.findings.unresolved(this.id, ctx.entityName, "the filtered set is larger than the walk covers, so the two runs cannot be compared " + "as whole sets");
1762
1747
  }
1763
1748
  const alone = new Set(ids(filtered.items, ctx.identity));
1764
1749
  const combined = new Set(ids(both.items, ctx.identity));
@@ -1771,13 +1756,13 @@ const filterAndSelectCompose = {
1771
1756
  const extraIds = [...combined].filter((id) => !alone.has(id));
1772
1757
  if (missing.length === 0 && extraIds.length === 0)
1773
1758
  return;
1774
- ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a filter returns", `filter on "${field}" alone matched ${alone.size} record(s); the same filter with `
1775
- + `select=${[ctx.identity, extra].join(",")} matched ${combined.size}. `
1776
- + (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
1777
- + (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
1778
- + "A projection must change which columns come back, never which rows — a filter "
1779
- + "that only holds while every column is selected is one a query plan is dropping "
1780
- + "once it has to name the columns.", [filtered.last.exchange, both.last.exchange]);
1759
+ ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a filter returns", `filter on "${field}" alone matched ${alone.size} record(s); the same filter with ` +
1760
+ `select=${[ctx.identity, extra].join(",")} matched ${combined.size}. ` +
1761
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "") +
1762
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "") +
1763
+ "A projection must change which columns come back, never which rows — a filter " +
1764
+ "that only holds while every column is selected is one a query plan is dropping " +
1765
+ "once it has to name the columns.", [filtered.last.exchange, both.last.exchange]);
1781
1766
  },
1782
1767
  };
1783
1768
  /**
@@ -1793,10 +1778,10 @@ const filterAndSelectCompose = {
1793
1778
  * intersection. The token is chosen so each axis matches something the other does not.
1794
1779
  */
1795
1780
  const searchAndFilterCompose = {
1796
- applicable: (ctx) => filterable(ctx)
1797
- && conv(ctx).search !== undefined
1798
- && (ctx.query?.searchable.length ?? 0) > 0
1799
- && ctx.records.length > 2,
1781
+ applicable: (ctx) => filterable(ctx) &&
1782
+ conv(ctx).search !== undefined &&
1783
+ (ctx.query?.searchable.length ?? 0) > 0 &&
1784
+ ctx.records.length > 2,
1800
1785
  dependsOn: [
1801
1786
  "list.read-after-write",
1802
1787
  "create.persists-submitted-fields",
@@ -1849,8 +1834,7 @@ const searchAndFilterCompose = {
1849
1834
  break;
1850
1835
  }
1851
1836
  if (field === undefined || target === undefined || token === null) {
1852
- return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field and search token overlap without nesting, so dropping either "
1853
- + "axis would be invisible");
1837
+ return ctx.findings.unresolved(this.id, ctx.entityName, "no filterable field and search token overlap without nesting, so dropping either " + "axis would be invisible");
1854
1838
  }
1855
1839
  const term = filterTerm(conventions, field, "eq", String(target[field]));
1856
1840
  if (term === null) {
@@ -1882,13 +1866,13 @@ const searchAndFilterCompose = {
1882
1866
  const extraIds = [...got].filter((id) => !expected.has(id));
1883
1867
  if (missing.length === 0 && extraIds.length === 0)
1884
1868
  return;
1885
- ctx.findings.backend(this.id, ctx.entityName, "a filter and a search do not compose as their intersection", `filter on "${field}" matched ${filterIds.size}; q=${JSON.stringify(token)} matched `
1886
- + `${searchIds.size}; both together matched ${got.size} (intersection is `
1887
- + `${expected.size}). `
1888
- + (missing.length > 0 ? `Missing: ${missing.slice(0, 3).join(", ")}. ` : "")
1889
- + (extraIds.length > 0 ? `Extra: ${extraIds.slice(0, 3).join(", ")}. ` : "")
1890
- + "A structured predicate and a free-text search must narrow each other. When they "
1891
- + "do not, one of them is being dropped the moment the other is present.", [filtered.last.exchange, searched.last.exchange, both.last.exchange]);
1869
+ ctx.findings.backend(this.id, ctx.entityName, "a filter and a search do not compose as their intersection", `filter on "${field}" matched ${filterIds.size}; q=${JSON.stringify(token)} matched ` +
1870
+ `${searchIds.size}; both together matched ${got.size} (intersection is ` +
1871
+ `${expected.size}). ` +
1872
+ (missing.length > 0 ? `Missing: ${missing.slice(0, 3).join(", ")}. ` : "") +
1873
+ (extraIds.length > 0 ? `Extra: ${extraIds.slice(0, 3).join(", ")}. ` : "") +
1874
+ "A structured predicate and a free-text search must narrow each other. When they " +
1875
+ "do not, one of them is being dropped the moment the other is present.", [filtered.last.exchange, searched.last.exchange, both.last.exchange]);
1892
1876
  },
1893
1877
  };
1894
1878
  /**
@@ -1901,11 +1885,11 @@ const searchAndFilterCompose = {
1901
1885
  * pairs: the filter alone and the filter with both extras must return the same set.
1902
1886
  */
1903
1887
  const filterSortSelectCompose = {
1904
- applicable: (ctx) => filterable(ctx)
1905
- && conv(ctx).order !== undefined
1906
- && conv(ctx).select !== undefined
1907
- && (ctx.query?.sortable.length ?? 0) > 0
1908
- && ctx.records.length > 2,
1888
+ applicable: (ctx) => filterable(ctx) &&
1889
+ conv(ctx).order !== undefined &&
1890
+ conv(ctx).select !== undefined &&
1891
+ (ctx.query?.sortable.length ?? 0) > 0 &&
1892
+ ctx.records.length > 2,
1909
1893
  dependsOn: [
1910
1894
  "list.read-after-write",
1911
1895
  "create.persists-submitted-fields",
@@ -1932,8 +1916,7 @@ const filterSortSelectCompose = {
1932
1916
  const term = filterTerm(conventions, field, "eq", String(target[field]));
1933
1917
  const extra = (ctx.query?.selectable ?? []).find((name) => name !== ctx.identity) ?? ctx.identity;
1934
1918
  const projection = selectTerm(conventions, [ctx.identity, extra], ctx.entityName);
1935
- const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity)
1936
- ?? ctx.identity;
1919
+ const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity) ?? ctx.identity;
1937
1920
  if (term === null || projection === null) {
1938
1921
  return ctx.findings.unresolved(this.id, ctx.entityName, "this API cannot express the filter, the sort or the select together");
1939
1922
  }
@@ -1954,13 +1937,13 @@ const filterSortSelectCompose = {
1954
1937
  const extraIds = [...combined].filter((id) => !alone.has(id));
1955
1938
  if (missing.length === 0 && extraIds.length === 0)
1956
1939
  return;
1957
- ctx.findings.backend(this.id, ctx.entityName, "adding a sort and a select together changes which records a filter returns", `filter on "${field}" alone matched ${alone.size}; the same filter with `
1958
- + `order=${sortField} desc and select=${[ctx.identity, extra].join(",")} matched `
1959
- + `${combined.size}. `
1960
- + (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
1961
- + (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
1962
- + "Each pair composes; the triple must too. A filter that only holds until both a "
1963
- + "sort and a projection are present is one a three-axis query plan is dropping.", [filtered.last.exchange, triple.last.exchange]);
1940
+ ctx.findings.backend(this.id, ctx.entityName, "adding a sort and a select together changes which records a filter returns", `filter on "${field}" alone matched ${alone.size}; the same filter with ` +
1941
+ `order=${sortField} desc and select=${[ctx.identity, extra].join(",")} matched ` +
1942
+ `${combined.size}. ` +
1943
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "") +
1944
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "") +
1945
+ "Each pair composes; the triple must too. A filter that only holds until both a " +
1946
+ "sort and a projection are present is one a three-axis query plan is dropping.", [filtered.last.exchange, triple.last.exchange]);
1964
1947
  },
1965
1948
  };
1966
1949
  /**
@@ -1971,12 +1954,12 @@ const filterSortSelectCompose = {
1971
1954
  * structured predicate. Pairwise, filter+search and filter+sort both hold; together they do not.
1972
1955
  */
1973
1956
  const filterSearchSortCompose = {
1974
- applicable: (ctx) => filterable(ctx)
1975
- && conv(ctx).search !== undefined
1976
- && conv(ctx).order !== undefined
1977
- && (ctx.query?.searchable.length ?? 0) > 0
1978
- && (ctx.query?.sortable.length ?? 0) > 0
1979
- && ctx.records.length > 2,
1957
+ applicable: (ctx) => filterable(ctx) &&
1958
+ conv(ctx).search !== undefined &&
1959
+ conv(ctx).order !== undefined &&
1960
+ (ctx.query?.searchable.length ?? 0) > 0 &&
1961
+ (ctx.query?.sortable.length ?? 0) > 0 &&
1962
+ ctx.records.length > 2,
1980
1963
  dependsOn: [
1981
1964
  "list.read-after-write",
1982
1965
  "create.persists-submitted-fields",
@@ -2002,8 +1985,7 @@ const filterSearchSortCompose = {
2002
1985
  if (term === null) {
2003
1986
  return ctx.findings.unresolved(this.id, ctx.entityName, `this API's filter grammar cannot express equality on "${field}"`);
2004
1987
  }
2005
- const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity)
2006
- ?? ctx.identity;
1988
+ const sortField = (ctx.query?.sortable ?? []).find((name) => name !== field && name !== ctx.identity) ?? ctx.identity;
2007
1989
  const pair = await collectSet(ctx, limit, {
2008
1990
  ...term,
2009
1991
  ...q(ctx, { search: token }),
@@ -2021,11 +2003,11 @@ const filterSearchSortCompose = {
2021
2003
  const extraIds = [...got].filter((id) => !expected.has(id));
2022
2004
  if (missing.length === 0 && extraIds.length === 0)
2023
2005
  return;
2024
- ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a filter and a search return", `filter on "${field}" with q=${JSON.stringify(token)} matched ${expected.size}; `
2025
- + `the same request with order=${sortField} desc matched ${got.size}. `
2026
- + (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
2027
- + (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
2028
- + "A sort must reorder the intersection, never change it.", [pair.last.exchange, triple.last.exchange]);
2006
+ ctx.findings.backend(this.id, ctx.entityName, "adding a sort changes which records a filter and a search return", `filter on "${field}" with q=${JSON.stringify(token)} matched ${expected.size}; ` +
2007
+ `the same request with order=${sortField} desc matched ${got.size}. ` +
2008
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "") +
2009
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "") +
2010
+ "A sort must reorder the intersection, never change it.", [pair.last.exchange, triple.last.exchange]);
2029
2011
  },
2030
2012
  };
2031
2013
  /**
@@ -2033,11 +2015,11 @@ const filterSearchSortCompose = {
2033
2015
  * intersection of filter and search.
2034
2016
  */
2035
2017
  const filterSearchSelectCompose = {
2036
- applicable: (ctx) => filterable(ctx)
2037
- && conv(ctx).search !== undefined
2038
- && conv(ctx).select !== undefined
2039
- && (ctx.query?.searchable.length ?? 0) > 0
2040
- && ctx.records.length > 2,
2018
+ applicable: (ctx) => filterable(ctx) &&
2019
+ conv(ctx).search !== undefined &&
2020
+ conv(ctx).select !== undefined &&
2021
+ (ctx.query?.searchable.length ?? 0) > 0 &&
2022
+ ctx.records.length > 2,
2041
2023
  dependsOn: [
2042
2024
  "list.read-after-write",
2043
2025
  "create.persists-submitted-fields",
@@ -2088,12 +2070,12 @@ const filterSearchSelectCompose = {
2088
2070
  const extraIds = [...got].filter((id) => !expected.has(id));
2089
2071
  if (missing.length === 0 && extraIds.length === 0)
2090
2072
  return;
2091
- ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a filter and a search return", `filter on "${field}" with q=${JSON.stringify(token)} matched ${expected.size}; `
2092
- + `the same request with select=${[ctx.identity, extra].join(",")} matched ${got.size}. `
2093
- + (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "")
2094
- + (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "")
2095
- + "A projection must change columns, never the intersection a filter and a search "
2096
- + "already agreed on.", [pair.last.exchange, triple.last.exchange]);
2073
+ ctx.findings.backend(this.id, ctx.entityName, "adding a select changes which records a filter and a search return", `filter on "${field}" with q=${JSON.stringify(token)} matched ${expected.size}; ` +
2074
+ `the same request with select=${[ctx.identity, extra].join(",")} matched ${got.size}. ` +
2075
+ (missing.length > 0 ? `Dropped: ${missing.slice(0, 3).join(", ")}. ` : "") +
2076
+ (extraIds.length > 0 ? `Appeared: ${extraIds.slice(0, 3).join(", ")}. ` : "") +
2077
+ "A projection must change columns, never the intersection a filter and a search " +
2078
+ "already agreed on.", [pair.last.exchange, triple.last.exchange]);
2097
2079
  },
2098
2080
  };
2099
2081
  /** A filterable field whose value selects a proper subset of the cohort. */
@@ -2156,7 +2138,9 @@ function overlappingSearchToken(ctx, filterIds) {
2156
2138
  const needle = token.toLowerCase();
2157
2139
  const hit = new Set();
2158
2140
  for (const record of ctx.records) {
2159
- if (fields.some((field) => String(record[field] ?? "").toLowerCase().includes(needle))) {
2141
+ if (fields.some((field) => String(record[field] ?? "")
2142
+ .toLowerCase()
2143
+ .includes(needle))) {
2160
2144
  hit.add(String(record[ctx.identity]));
2161
2145
  }
2162
2146
  }
@@ -2203,10 +2187,10 @@ function overlappingSearchToken(ctx, filterIds) {
2203
2187
  * stands down, which is right when the field was merely inferred and wrong when it was declared.
2204
2188
  */
2205
2189
  const declaredFilterableWorks = {
2206
- applicable: (ctx) => conv(ctx).filter !== undefined
2207
- && ctx.query?.source === "tag"
2208
- && (ctx.query?.filterable.length ?? 0) > 0
2209
- && ctx.records.length > 0,
2190
+ applicable: (ctx) => conv(ctx).filter !== undefined &&
2191
+ ctx.query?.source === "tag" &&
2192
+ (ctx.query?.filterable.length ?? 0) > 0 &&
2193
+ ctx.records.length > 0,
2210
2194
  dependsOn: [
2211
2195
  "list.read-after-write",
2212
2196
  /* The evidence is a *rejection*. A backend that silently drops unknown filter fields never
@@ -2244,11 +2228,11 @@ const declaredFilterableWorks = {
2244
2228
  }
2245
2229
  if (rejected.length === 0)
2246
2230
  return;
2247
- ctx.findings.spec(this.id, ctx.entityName, "the document declares a filter the backend does not accept", `x-query lists ${rejected.length} field(s) as filterable that the backend rejects: `
2248
- + `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. `
2249
- + "Every client generated from this document will offer a filter that fails at runtime. "
2250
- + "Either the column needs an index or the declaration needs removing — but the "
2251
- + "document and the backend currently disagree about what this API can do.", []);
2231
+ ctx.findings.spec(this.id, ctx.entityName, "the document declares a filter the backend does not accept", `x-query lists ${rejected.length} field(s) as filterable that the backend rejects: ` +
2232
+ `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. ` +
2233
+ "Every client generated from this document will offer a filter that fails at runtime. " +
2234
+ "Either the column needs an index or the declaration needs removing — but the " +
2235
+ "document and the backend currently disagree about what this API can do.", []);
2252
2236
  },
2253
2237
  };
2254
2238
  /**
@@ -2257,10 +2241,10 @@ const declaredFilterableWorks = {
2257
2241
  * the same way a rejected filter does.
2258
2242
  */
2259
2243
  const declaredSortableWorks = {
2260
- applicable: (ctx) => conv(ctx).order !== undefined
2261
- && ctx.query?.source === "tag"
2262
- && (ctx.query?.sortable.length ?? 0) > 0
2263
- && ctx.records.length > 0,
2244
+ applicable: (ctx) => conv(ctx).order !== undefined &&
2245
+ ctx.query?.source === "tag" &&
2246
+ (ctx.query?.sortable.length ?? 0) > 0 &&
2247
+ ctx.records.length > 0,
2264
2248
  dependsOn: [
2265
2249
  "list.read-after-write",
2266
2250
  /* ERROR_500_ON_BAD_FILTER, despite its name, turns *any* SqlError the reference throws
@@ -2296,11 +2280,11 @@ const declaredSortableWorks = {
2296
2280
  }
2297
2281
  if (rejected.length === 0)
2298
2282
  return;
2299
- ctx.findings.spec(this.id, ctx.entityName, "the document declares a sort field the backend does not accept", `x-query lists ${rejected.length} field(s) as sortable that the backend rejects: `
2300
- + `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. `
2301
- + "Every client generated from this document will offer an order value that fails at "
2302
- + "runtime. Either the column needs an index or the declaration needs removing — but "
2303
- + "the document and the backend currently disagree about what this API can do.", []);
2283
+ ctx.findings.spec(this.id, ctx.entityName, "the document declares a sort field the backend does not accept", `x-query lists ${rejected.length} field(s) as sortable that the backend rejects: ` +
2284
+ `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. ` +
2285
+ "Every client generated from this document will offer an order value that fails at " +
2286
+ "runtime. Either the column needs an index or the declaration needs removing — but " +
2287
+ "the document and the backend currently disagree about what this API can do.", []);
2304
2288
  },
2305
2289
  };
2306
2290
  /**
@@ -2309,10 +2293,10 @@ const declaredSortableWorks = {
2309
2293
  * client generated from the document exactly the same way a rejected filter or sort does.
2310
2294
  */
2311
2295
  const declaredSelectableWorks = {
2312
- applicable: (ctx) => conv(ctx).select !== undefined
2313
- && ctx.query?.source === "tag"
2314
- && (ctx.query?.selectable.length ?? 0) > 0
2315
- && ctx.records.length > 0,
2296
+ applicable: (ctx) => conv(ctx).select !== undefined &&
2297
+ ctx.query?.source === "tag" &&
2298
+ (ctx.query?.selectable.length ?? 0) > 0 &&
2299
+ ctx.records.length > 0,
2316
2300
  dependsOn: [
2317
2301
  "list.read-after-write",
2318
2302
  /* Same masking risk as the filter and sort overclaim checks: ERROR_500_ON_BAD_FILTER turns
@@ -2348,12 +2332,12 @@ const declaredSelectableWorks = {
2348
2332
  }
2349
2333
  if (rejected.length === 0)
2350
2334
  return;
2351
- ctx.findings.spec(this.id, ctx.entityName, "the document declares a select field the backend does not accept", `x-query lists ${rejected.length} field(s) as selectable that the backend rejects: `
2352
- + `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. `
2353
- + "Every client generated from this document will offer a sparse fieldset that fails at "
2354
- + "runtime. Either the field needs to stay projectable or the declaration needs "
2355
- + "removing — but the document and the backend currently disagree about what this API "
2356
- + "can do.", []);
2335
+ ctx.findings.spec(this.id, ctx.entityName, "the document declares a select field the backend does not accept", `x-query lists ${rejected.length} field(s) as selectable that the backend rejects: ` +
2336
+ `${rejected.map((r) => `${r.field} (${r.status})`).join(", ")}. ` +
2337
+ "Every client generated from this document will offer a sparse fieldset that fails at " +
2338
+ "runtime. Either the field needs to stay projectable or the declaration needs " +
2339
+ "removing — but the document and the backend currently disagree about what this API " +
2340
+ "can do.", []);
2357
2341
  },
2358
2342
  };
2359
2343
  const crossTenantItemRead = {
@@ -2398,10 +2382,7 @@ const crossTenantItemRead = {
2398
2382
  },
2399
2383
  };
2400
2384
  const crossTenantFilterBypass = {
2401
- applicable: (ctx) => ctx.altAuth !== undefined &&
2402
- ctx.altScope !== undefined &&
2403
- filterable(ctx) &&
2404
- ctx.records.length > 0,
2385
+ applicable: (ctx) => ctx.altAuth !== undefined && ctx.altScope !== undefined && filterable(ctx) && ctx.records.length > 0,
2405
2386
  /* The probe filters, so anything that stops a filter from selecting over the whole collection
2406
2387
  * hides the very record whose visibility is in question — a leak that cannot be observed is
2407
2388
  * not a leak that can be reported. */
@@ -2498,7 +2479,10 @@ const rankIsMonotonic = {
2498
2479
  },
2499
2480
  };
2500
2481
  function pointerValue(body, pointer) {
2501
- const path = pointer.replace(/^\$\.?/, "").split(".").filter(Boolean);
2482
+ const path = pointer
2483
+ .replace(/^\$\.?/, "")
2484
+ .split(".")
2485
+ .filter(Boolean);
2502
2486
  let node = body;
2503
2487
  for (const segment of path) {
2504
2488
  if (node === null || typeof node !== "object")
@@ -2514,12 +2498,12 @@ function pointerValue(body, pointer) {
2514
2498
  * at the same check — the timeline is the property.
2515
2499
  */
2516
2500
  const inviteGrantsThenRevokes = {
2517
- applicable: (ctx) => ctx.invite !== null
2518
- && ctx.readOp !== undefined
2519
- && ctx.records.length > 0
2520
- && ctx.actors.some((actor) => actor.inviteAs !== undefined
2521
- && ctx.actors[0] !== undefined
2522
- && !sameTenantScope(actor.roots, ctx.actors[0].roots)),
2501
+ applicable: (ctx) => ctx.invite !== null &&
2502
+ ctx.readOp !== undefined &&
2503
+ ctx.records.length > 0 &&
2504
+ ctx.actors.some((actor) => actor.inviteAs !== undefined &&
2505
+ ctx.actors[0] !== undefined &&
2506
+ !sameTenantScope(actor.roots, ctx.actors[0].roots)),
2523
2507
  dependsOn: ["list.read-after-write", "tenant.item-not-readable-cross-tenant"],
2524
2508
  id: "auth.invite-grants-then-revokes",
2525
2509
  needs: "x-invite naming invite/accept/revoke, a peer principal with inviteAs, and an item route",
@@ -2569,7 +2553,9 @@ const inviteGrantsThenRevokes = {
2569
2553
  if (token === undefined) {
2570
2554
  return ctx.findings.unresolved(this.id, ctx.entityName, `invite response has no token at ${spec.tokenPointer}`);
2571
2555
  }
2572
- const accepted = await ctx.client.request(acceptOp.method, fillPath(acceptOp.path, { token }), { headers: delegate.headers() });
2556
+ const accepted = await ctx.client.request(acceptOp.method, fillPath(acceptOp.path, { token }), {
2557
+ headers: delegate.headers(),
2558
+ });
2573
2559
  if (accepted.status >= 300) {
2574
2560
  return ctx.findings.unresolved(this.id, ctx.entityName, `accept returned ${accepted.status}`);
2575
2561
  }
@@ -2683,7 +2669,10 @@ const orderChangesResult = {
2683
2669
  if (JSON.stringify(values) === JSON.stringify(sorted))
2684
2670
  return;
2685
2671
  ctx.findings.backend(this.id, ctx.entityName, "order is accepted but the result is not sorted", `order=${field}.asc returned records whose "${field}" values are not ascending: ` +
2686
- `${values.slice(0, 5).map((v) => JSON.stringify(v)).join(", ")}. A sort parameter the ` +
2672
+ `${values
2673
+ .slice(0, 5)
2674
+ .map((v) => JSON.stringify(v))
2675
+ .join(", ")}. A sort parameter the ` +
2687
2676
  "backend ignores silently gives every caller arbitrary ordering.", [ascending.exchange]);
2688
2677
  },
2689
2678
  };
@@ -2699,9 +2688,7 @@ function compareValues(a, b) {
2699
2688
  return as < bs ? -1 : as > bs ? 1 : 0;
2700
2689
  }
2701
2690
  const searchNarrowsResult = {
2702
- applicable: (ctx) => conv(ctx).search !== undefined &&
2703
- (ctx.query?.searchable.length ?? 0) > 0 &&
2704
- ctx.records.length > 2,
2691
+ applicable: (ctx) => conv(ctx).search !== undefined && (ctx.query?.searchable.length ?? 0) > 0 && ctx.records.length > 2,
2705
2692
  dependsOn: ["list.read-after-write"],
2706
2693
  id: "search.q-narrows-result",
2707
2694
  needs: "a free-text `q` parameter and declared searchable fields",
@@ -2907,8 +2894,8 @@ const successSchemaHonoured = {
2907
2894
  if (exchange === undefined)
2908
2895
  return;
2909
2896
  if (!validator.documents(raw, exchange.status)) {
2910
- return ctx.findings.unresolved(this.id, ctx.entityName, `create returned ${exchange.status}, which the document declares no schema for — `
2911
- + "there is nothing to validate the body against");
2897
+ return ctx.findings.unresolved(this.id, ctx.entityName, `create returned ${exchange.status}, which the document declares no schema for — ` +
2898
+ "there is nothing to validate the body against");
2912
2899
  }
2913
2900
  const result = validator.validate(createOp.operationId, raw, exchange.status, exchange.responseBody);
2914
2901
  if (result.ok)
@@ -2947,9 +2934,7 @@ function findConstrained(schema, predicate) {
2947
2934
  return { name, schema: raw };
2948
2935
  const union = raw.oneOf ?? raw.anyOf;
2949
2936
  if (Array.isArray(union)) {
2950
- const branch = union.find((candidate) => candidate !== null &&
2951
- typeof candidate === "object" &&
2952
- predicate(candidate));
2937
+ const branch = union.find((candidate) => candidate !== null && typeof candidate === "object" && predicate(candidate));
2953
2938
  if (branch !== undefined)
2954
2939
  return { name, schema: branch };
2955
2940
  }
@@ -2977,9 +2962,7 @@ function findConstrained(schema, predicate) {
2977
2962
  * subset rather than an error, which is why it survives in production.
2978
2963
  */
2979
2964
  const numericComparisonIsNumeric = {
2980
- applicable: (ctx) => filterable(ctx) &&
2981
- numericFilterField(ctx) !== null &&
2982
- ctx.records.length > 2,
2965
+ applicable: (ctx) => filterable(ctx) && numericFilterField(ctx) !== null && ctx.records.length > 2,
2983
2966
  dependsOn: ["list.read-after-write", "filter.unknown-field-rejected"],
2984
2967
  id: "filter.numeric-comparison-is-numeric",
2985
2968
  needs: "a `filter` parameter and a numeric field",
@@ -2987,9 +2970,7 @@ const numericComparisonIsNumeric = {
2987
2970
  const field = numericFilterField(ctx);
2988
2971
  if (field === null)
2989
2972
  return;
2990
- const values = ctx.records
2991
- .map((r) => r[field])
2992
- .filter((v) => typeof v === "number");
2973
+ const values = ctx.records.map((r) => r[field]).filter((v) => typeof v === "number");
2993
2974
  if (values.length < 3)
2994
2975
  return;
2995
2976
  /* A threshold that partitions the cohort, chosen so the lexical and numeric answers
@@ -3072,8 +3053,7 @@ const noLostUpdate = {
3072
3053
  const params = { ...ctx.scope, ...itemParamFor(ctx, id) };
3073
3054
  const before = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
3074
3055
  if (before.status >= 300) {
3075
- return ctx.findings.unresolved(this.id, ctx.entityName, `reading the record back returned ${before.status}, so there was no baseline to race `
3076
- + "against");
3056
+ return ctx.findings.unresolved(this.id, ctx.entityName, `reading the record back returned ${before.status}, so there was no baseline to race ` + "against");
3077
3057
  }
3078
3058
  const original = (before.responseBody ?? {});
3079
3059
  /* Two distinct writable string fields, so the writes cannot legitimately clobber each
@@ -3081,8 +3061,8 @@ const noLostUpdate = {
3081
3061
  const fields = writableStringFields(ctx, original).slice(0, 2);
3082
3062
  const [first, second] = fields;
3083
3063
  if (first === undefined || second === undefined) {
3084
- return ctx.findings.unresolved(this.id, ctx.entityName, `only ${fields.length} writable string field(s) are present on the record; two are `
3085
- + "needed so the concurrent writes cannot legitimately clobber each other");
3064
+ return ctx.findings.unresolved(this.id, ctx.entityName, `only ${fields.length} writable string field(s) are present on the record; two are ` +
3065
+ "needed so the concurrent writes cannot legitimately clobber each other");
3086
3066
  }
3087
3067
  const path = fillPath(ctx.updateOp.path, params);
3088
3068
  const [firstWrite, secondWrite] = await Promise.all([
@@ -3096,8 +3076,8 @@ const noLostUpdate = {
3096
3076
  }),
3097
3077
  ]);
3098
3078
  if (firstWrite.status >= 300 || secondWrite.status >= 300) {
3099
- return ctx.findings.unresolved(this.id, ctx.entityName, `a concurrent PATCH was rejected (${firstWrite.status}, ${secondWrite.status}), so no `
3100
- + "race actually took place");
3079
+ return ctx.findings.unresolved(this.id, ctx.entityName, `a concurrent PATCH was rejected (${firstWrite.status}, ${secondWrite.status}), so no ` +
3080
+ "race actually took place");
3101
3081
  }
3102
3082
  const after = await ctx.client.get(fillPath(ctx.readOp.path, params), { headers: ctx.auth() });
3103
3083
  if (after.status >= 300) {
@@ -3201,11 +3181,7 @@ const declaredEffectsOccur = {
3201
3181
  const delta = after.ids.length - before.ids.length;
3202
3182
  const added = after.ids.filter((id) => !before.ids.includes(id));
3203
3183
  const removed = before.ids.filter((id) => !after.ids.includes(id));
3204
- const wanted = effect.op === "create" || effect.op === "append"
3205
- ? expected
3206
- : effect.op === "delete"
3207
- ? -expected
3208
- : 0;
3184
+ const wanted = effect.op === "create" || effect.op === "append" ? expected : effect.op === "delete" ? -expected : 0;
3209
3185
  if (effect.op === "create" || effect.op === "append") {
3210
3186
  if (delta === wanted && added.length === expected)
3211
3187
  continue;
@@ -3259,9 +3235,7 @@ const asyncReachesTerminalState = {
3259
3235
  const spec = op.async;
3260
3236
  if (spec === null)
3261
3237
  continue;
3262
- const body = op.hasRequestBody
3263
- ? validBody(ctx, requestSchemaOf(ctx, op) ?? {})
3264
- : undefined;
3238
+ const body = op.hasRequestBody ? validBody(ctx, requestSchemaOf(ctx, op) ?? {}) : undefined;
3265
3239
  const start = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3266
3240
  headers: ctx.auth(),
3267
3241
  ...(body === undefined ? {} : { body }),
@@ -3311,7 +3285,10 @@ const asyncReceiptIsResolvable = {
3311
3285
  if (started === undefined)
3312
3286
  continue;
3313
3287
  const body = started.responseBody;
3314
- const path = spec.idFrom.replace(/^\$\.?/, "").split(".").filter(Boolean);
3288
+ const path = spec.idFrom
3289
+ .replace(/^\$\.?/, "")
3290
+ .split(".")
3291
+ .filter(Boolean);
3315
3292
  let node = body;
3316
3293
  for (const segment of path) {
3317
3294
  if (node === null || typeof node !== "object") {