@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.
- package/README.md +1316 -176
- package/dist/cli.js +18 -16
- package/dist/config/define-config.d.ts +50 -0
- package/dist/config/define-config.d.ts.map +1 -1
- package/dist/config/define-config.js.map +1 -1
- package/dist/config/load.d.ts +1 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/reference/defects.d.ts.map +1 -1
- package/dist/reference/defects.js +4 -1
- package/dist/reference/defects.js.map +1 -1
- package/dist/reference/dialect.d.ts.map +1 -1
- package/dist/reference/dialect.js +4 -1
- package/dist/reference/dialect.js.map +1 -1
- package/dist/reference/http.d.ts.map +1 -1
- package/dist/reference/http.js +15 -22
- package/dist/reference/http.js.map +1 -1
- package/dist/reference/model.d.ts.map +1 -1
- package/dist/reference/model.js +27 -3
- package/dist/reference/model.js.map +1 -1
- package/dist/reference/query.d.ts.map +1 -1
- package/dist/reference/query.js +19 -35
- package/dist/reference/query.js.map +1 -1
- package/dist/reference/spec.d.ts.map +1 -1
- package/dist/reference/spec.js +8 -6
- package/dist/reference/spec.js.map +1 -1
- package/dist/reference/store-api.d.ts.map +1 -1
- package/dist/reference/store-api.js +4 -1
- package/dist/reference/store-api.js.map +1 -1
- package/dist/reference/stores/memory.d.ts.map +1 -1
- package/dist/reference/stores/memory.js +1 -3
- package/dist/reference/stores/memory.js.map +1 -1
- package/dist/reference/stores/postgres.d.ts.map +1 -1
- package/dist/reference/stores/postgres.js +8 -8
- package/dist/reference/stores/postgres.js.map +1 -1
- package/dist/reference/stores/sqlite-driver.d.ts.map +1 -1
- package/dist/reference/stores/sqlite-driver.js +2 -4
- package/dist/reference/stores/sqlite-driver.js.map +1 -1
- package/dist/reference/stores/sqlite.d.ts.map +1 -1
- package/dist/reference/stores/sqlite.js +22 -27
- package/dist/reference/stores/sqlite.js.map +1 -1
- package/dist/report/console.d.ts.map +1 -1
- package/dist/report/console.js +4 -4
- package/dist/report/console.js.map +1 -1
- package/dist/report/matrix.d.ts +6 -5
- package/dist/report/matrix.d.ts.map +1 -1
- package/dist/report/matrix.js +82 -52
- package/dist/report/matrix.js.map +1 -1
- package/dist/report/render.d.ts +8 -0
- package/dist/report/render.d.ts.map +1 -1
- package/dist/report/render.js +40 -20
- package/dist/report/render.js.map +1 -1
- package/dist/runtime/async.d.ts.map +1 -1
- package/dist/runtime/async.js +4 -1
- package/dist/runtime/async.js.map +1 -1
- package/dist/runtime/auth.d.ts.map +1 -1
- package/dist/runtime/auth.js +12 -16
- package/dist/runtime/auth.js.map +1 -1
- package/dist/runtime/checks.d.ts.map +1 -1
- package/dist/runtime/checks.js +195 -218
- package/dist/runtime/checks.js.map +1 -1
- package/dist/runtime/client.d.ts +12 -1
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js +17 -2
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/finding.d.ts.map +1 -1
- package/dist/runtime/finding.js.map +1 -1
- package/dist/runtime/fixture.d.ts.map +1 -1
- package/dist/runtime/fixture.js.map +1 -1
- package/dist/runtime/profile.d.ts +30 -0
- package/dist/runtime/profile.d.ts.map +1 -0
- package/dist/runtime/profile.js +46 -0
- package/dist/runtime/profile.js.map +1 -0
- package/dist/runtime/progress.js +4 -4
- package/dist/runtime/progress.js.map +1 -1
- package/dist/runtime/rate-limit.d.ts +53 -0
- package/dist/runtime/rate-limit.d.ts.map +1 -0
- package/dist/runtime/rate-limit.js +165 -0
- package/dist/runtime/rate-limit.js.map +1 -0
- package/dist/runtime/run.d.ts +15 -1
- package/dist/runtime/run.d.ts.map +1 -1
- package/dist/runtime/run.js +137 -51
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/validate.d.ts.map +1 -1
- package/dist/runtime/validate.js.map +1 -1
- package/dist/runtime/world.d.ts.map +1 -1
- package/dist/runtime/world.js.map +1 -1
- package/dist/spec/collection.d.ts.map +1 -1
- package/dist/spec/collection.js +2 -5
- package/dist/spec/collection.js.map +1 -1
- package/dist/spec/conventions.d.ts.map +1 -1
- package/dist/spec/conventions.js +2 -2
- package/dist/spec/conventions.js.map +1 -1
- package/dist/spec/extensions.d.ts +11 -0
- package/dist/spec/extensions.d.ts.map +1 -1
- package/dist/spec/extensions.js +17 -6
- package/dist/spec/extensions.js.map +1 -1
- package/dist/spec/graph.d.ts +2 -1
- package/dist/spec/graph.d.ts.map +1 -1
- package/dist/spec/graph.js +4 -4
- package/dist/spec/graph.js.map +1 -1
- package/dist/spec/load.d.ts.map +1 -1
- package/dist/spec/load.js +1 -3
- package/dist/spec/load.js.map +1 -1
- package/labs/README.md +63 -63
- package/labs/annotated-openapi.yaml +5 -5
- package/labs/demo.mjs +2 -10
- package/labs/{anyrow.config.ts → oob-auth.config.ts} +9 -9
- package/package.json +89 -78
package/dist/runtime/checks.js
CHANGED
|
@@ -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) }), {
|
|
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
|
-
|
|
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 &&
|
|
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) }), {
|
|
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
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
&&
|
|
1435
|
-
|
|
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
|
-
|
|
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) }), {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
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
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
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
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
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
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
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
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
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] ?? "")
|
|
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
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
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
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
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
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
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
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
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
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
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
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
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
|
|
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
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
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 }), {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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") {
|