@lovrozagar/oat 0.7.4 → 0.7.6

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 (52) hide show
  1. package/README.md +175 -146
  2. package/dist/cli.js +26 -1
  3. package/dist/config/define-config.d.ts +17 -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/index.d.ts +2 -0
  7. package/dist/index.js +1 -0
  8. package/dist/report/console.d.ts.map +1 -1
  9. package/dist/report/console.js +1 -0
  10. package/dist/report/console.js.map +1 -1
  11. package/dist/report/render.d.ts +11 -0
  12. package/dist/report/render.d.ts.map +1 -1
  13. package/dist/report/render.js +9 -0
  14. package/dist/report/render.js.map +1 -1
  15. package/dist/runtime/checks.d.ts.map +1 -1
  16. package/dist/runtime/checks.js +120 -58
  17. package/dist/runtime/checks.js.map +1 -1
  18. package/dist/runtime/client.d.ts +22 -2
  19. package/dist/runtime/client.d.ts.map +1 -1
  20. package/dist/runtime/client.js +72 -8
  21. package/dist/runtime/client.js.map +1 -1
  22. package/dist/runtime/effects.d.ts +43 -0
  23. package/dist/runtime/effects.d.ts.map +1 -0
  24. package/dist/runtime/effects.js +141 -0
  25. package/dist/runtime/effects.js.map +1 -0
  26. package/dist/runtime/exchanges.d.ts +37 -0
  27. package/dist/runtime/exchanges.d.ts.map +1 -0
  28. package/dist/runtime/exchanges.js +298 -0
  29. package/dist/runtime/exchanges.js.map +1 -0
  30. package/dist/runtime/network.d.ts +74 -0
  31. package/dist/runtime/network.d.ts.map +1 -0
  32. package/dist/runtime/network.js +270 -0
  33. package/dist/runtime/network.js.map +1 -0
  34. package/dist/runtime/progress.d.ts +2 -0
  35. package/dist/runtime/progress.d.ts.map +1 -1
  36. package/dist/runtime/progress.js +2 -2
  37. package/dist/runtime/progress.js.map +1 -1
  38. package/dist/runtime/run.d.ts +23 -0
  39. package/dist/runtime/run.d.ts.map +1 -1
  40. package/dist/runtime/run.js +107 -8
  41. package/dist/runtime/run.js.map +1 -1
  42. package/dist/spec/extensions.d.ts +4 -1
  43. package/dist/spec/extensions.d.ts.map +1 -1
  44. package/dist/spec/extensions.js +8 -2
  45. package/dist/spec/extensions.js.map +1 -1
  46. package/dist/spec/graph.js +1 -1
  47. package/dist/spec/graph.js.map +1 -1
  48. package/dist/spec/load.d.ts +2 -1
  49. package/dist/spec/load.d.ts.map +1 -1
  50. package/dist/spec/load.js +8 -7
  51. package/dist/spec/load.js.map +1 -1
  52. package/package.json +1 -1
@@ -18,6 +18,7 @@ import { buildCohort } from "./fixture.js";
18
18
  import { forEachInvocation } from "./upload-each.js";
19
19
  import { driveWait } from "./wait.js";
20
20
  import { fillPath } from "./world.js";
21
+ import { bindAfterCreateEffects, bindCreatedScope, bindMissingPathParams, canFillPath, describeEffectHold, effectHolds, identityPathParam, } from "./effects.js";
21
22
  /* ------------------------------------------------------------------- helpers */
22
23
  /**
23
24
  * A documented feature-gate 403 is not a 2xx-check failure and not SECURITY.
@@ -3265,11 +3266,15 @@ function findConstrained(schema, predicate) {
3265
3266
  * downstream consequence.
3266
3267
  */
3267
3268
  /**
3268
- * An operation declaring `x-effects` must produce exactly the stated change.
3269
+ * An operation declaring `x-effects` must produce the stated change.
3270
+ *
3271
+ * `count` is an exact cardinality delta (default 1). `min` is at-least. After a `create` on A,
3272
+ * later items in the same array fill a child list under A with the new id — from the write
3273
+ * response (`table_id` or the entity identity) or from A's list delta.
3269
3274
  *
3270
3275
  * `x-invalidate` says a read route changes; `x-effects` says *how*. That difference is what
3271
3276
  * separates "something differed" — which is satisfied by a stray timestamp and missed by a
3272
- * cache-stale read — from an exact assertion on cardinality and membership.
3277
+ * cache-stale read — from an assertion on cardinality and membership.
3273
3278
  */
3274
3279
  /**
3275
3280
  * A numeric field must compare numerically, not lexically.
@@ -3477,64 +3482,108 @@ const declaredEffectsOccur = {
3477
3482
  needs: "an operation declaring x-effects",
3478
3483
  async run(ctx) {
3479
3484
  for (const op of ctx.effectOps) {
3480
- for (const effect of op.effects) {
3481
- const target = ctx.model.entities.get(effect.entity);
3482
- const listId = target?.list;
3483
- if (listId === undefined) {
3484
- ctx.findings.gap(this.id, ctx.entityName, `${op.operationId} declares an effect on "${effect.entity}", which has no list route`, "the effect cannot be observed, so it is not verified");
3485
- continue;
3485
+ await forEachInvocation(op.operationId, ctx.uploads, async (uploads, slot) => {
3486
+ const fixture = slot?.filename;
3487
+ const effects = op.effects;
3488
+ if (effects.length === 0)
3489
+ return;
3490
+ const scope = { ...ctx.scope };
3491
+ const befores = new Map();
3492
+ for (const effect of effects) {
3493
+ const listOp = listOpFor(ctx, effect.entity);
3494
+ if (listOp === undefined)
3495
+ continue;
3496
+ if (befores.has(effect.entity))
3497
+ continue;
3498
+ befores.set(effect.entity, await observe(ctx, listOp, scope));
3486
3499
  }
3487
- const listOp = ctx.model.byOperationId.get(listId);
3488
- if (listOp === undefined)
3489
- continue;
3490
- await forEachInvocation(op.operationId, ctx.uploads, async (uploads, slot) => {
3491
- const before = await observe(ctx, listOp);
3492
- if (before === null)
3493
- return;
3494
- const fixture = slot?.filename;
3495
- const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3496
- const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3497
- headers: ctx.auth(),
3498
- ...(body === undefined ? {} : await encodeOpBody(ctx, op, body, "baseline", 0, uploads)),
3499
- });
3500
- if (standDownForFeatureGate(ctx, op, invoked, this.id))
3501
- return;
3502
- if (standDownForRateLimit(ctx, invoked, this.id))
3503
- return;
3504
- if (invoked.status >= 400) {
3505
- ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`, fixture);
3506
- return;
3500
+ const body = op.hasRequestBody ? bodyForOp(ctx, op) : undefined;
3501
+ const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3502
+ headers: ctx.auth(),
3503
+ operationId: op.operationId,
3504
+ ...(fixture === undefined ? {} : { fixture }),
3505
+ ...(body === undefined ? {} : await encodeOpBody(ctx, op, body, "baseline", 0, uploads)),
3506
+ });
3507
+ if (standDownForFeatureGate(ctx, op, invoked, this.id))
3508
+ return;
3509
+ if (standDownForRateLimit(ctx, invoked, this.id))
3510
+ return;
3511
+ if (invoked.status >= 400) {
3512
+ ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared effects are unverified`, fixture);
3513
+ return;
3514
+ }
3515
+ const deltas = new Map();
3516
+ Object.assign(scope, bindAfterCreateEffects(ctx.model, effects, invoked.responseBody, deltas));
3517
+ for (const effect of effects) {
3518
+ if (effect.op !== "create")
3519
+ continue;
3520
+ const param = identityPathParam(ctx.model, effect.entity);
3521
+ if (scope[param] !== undefined)
3522
+ continue;
3523
+ const listOp = listOpFor(ctx, effect.entity);
3524
+ if (listOp === undefined)
3525
+ continue;
3526
+ const afterParent = await observe(ctx, listOp, scope);
3527
+ if (afterParent.status !== "ok")
3528
+ continue;
3529
+ const before = befores.get(effect.entity);
3530
+ const prior = before?.status === "ok" ? before.ids : [];
3531
+ const added = afterParent.ids.filter((id) => !prior.includes(id));
3532
+ deltas.set(effect.entity, added);
3533
+ Object.assign(scope, bindCreatedScope(ctx.model, effect.entity, invoked.responseBody, added));
3534
+ }
3535
+ for (const effect of effects) {
3536
+ const listOp = listOpFor(ctx, effect.entity);
3537
+ if (listOp === undefined) {
3538
+ ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} declares an effect on "${effect.entity}", which has no list route`, "the effect cannot be observed, so it is not verified", fixture);
3539
+ continue;
3540
+ }
3541
+ if (!canFillPath(listOp.path, scope)) {
3542
+ const missing = listOp.pathParams.filter((name) => scope[name] === undefined);
3543
+ ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} declares an effect on "${effect.entity}", but the list path cannot be filled`, `missing ${missing.map((name) => `{${name}}`).join(", ")} after the write; the effect is unverified`, fixture);
3544
+ continue;
3545
+ }
3546
+ const after = await observe(ctx, listOp, scope);
3547
+ if (after.status === "error") {
3548
+ ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not observe "${effect.entity}" after the write`, `list returned ${after.exchange.status}; the declared effect is unverified`, fixture);
3549
+ continue;
3507
3550
  }
3508
- const after = await observe(ctx, listOp);
3509
- if (after === null)
3510
- return;
3511
- const expected = effect.count ?? 1;
3512
- const delta = after.ids.length - before.ids.length;
3513
- const added = after.ids.filter((id) => !before.ids.includes(id));
3514
- const removed = before.ids.filter((id) => !after.ids.includes(id));
3515
- const wanted = effect.op === "create" || effect.op === "append" ? expected : effect.op === "delete" ? -expected : 0;
3551
+ if (after.status === "unfillable") {
3552
+ ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} declares an effect on "${effect.entity}", but the list path cannot be filled`, "the child list still lacks a parent id after the write; the effect is unverified", fixture);
3553
+ continue;
3554
+ }
3555
+ const before = befores.get(effect.entity);
3556
+ if (before?.status === "error") {
3557
+ ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not observe "${effect.entity}" before the write`, `list returned ${before.exchange.status}; no baseline, so the effect is unverified`, fixture);
3558
+ continue;
3559
+ }
3560
+ const prior = before?.status === "ok" ? before.ids : [];
3561
+ const delta = after.ids.length - prior.length;
3562
+ const added = after.ids.filter((id) => !prior.includes(id));
3563
+ const removed = prior.filter((id) => !after.ids.includes(id));
3564
+ if (effectHolds(effect, delta, added.length, removed.length)) {
3565
+ if (effect.op === "create") {
3566
+ Object.assign(scope, bindCreatedScope(ctx.model, effect.entity, invoked.responseBody, added));
3567
+ }
3568
+ continue;
3569
+ }
3570
+ const hold = describeEffectHold(effect);
3516
3571
  if (effect.op === "create" || effect.op === "append") {
3517
- if (delta === wanted && added.length === expected)
3518
- return;
3519
- ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} did not produce the "${effect.entity}" records it declares`, `x-effects declares ${effect.op} × ${expected} on "${effect.entity}", but the ` +
3520
- `collection went from ${before.ids.length} to ${after.ids.length} ` +
3572
+ ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} did not produce the "${effect.entity}" records it declares`, `x-effects declares ${hold} on "${effect.entity}", but the ` +
3573
+ `collection went from ${prior.length} to ${after.ids.length} ` +
3521
3574
  `(${added.length} added, ${removed.length} removed). A declared effect that does ` +
3522
3575
  "not occur means callers cannot rely on the operation having done anything.", [invoked, after.exchange], fixture);
3523
- return;
3576
+ continue;
3524
3577
  }
3525
3578
  if (effect.op === "delete") {
3526
- if (delta === wanted && removed.length === expected)
3527
- return;
3528
- ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} did not remove the "${effect.entity}" records it declares`, `x-effects declares delete × ${expected} on "${effect.entity}", but the collection ` +
3529
- `went from ${before.ids.length} to ${after.ids.length}`, [invoked, after.exchange], fixture);
3530
- return;
3531
- }
3532
- if (delta !== 0) {
3533
- ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} changed the size of "${effect.entity}" while declaring ${effect.op}`, `x-effects declares ${effect.op}, which must not add or remove records, but the ` +
3534
- `collection went from ${before.ids.length} to ${after.ids.length}`, [invoked, after.exchange], fixture);
3579
+ ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} did not remove the "${effect.entity}" records it declares`, `x-effects declares ${hold} on "${effect.entity}", but the collection ` +
3580
+ `went from ${prior.length} to ${after.ids.length}`, [invoked, after.exchange], fixture);
3581
+ continue;
3535
3582
  }
3536
- });
3537
- }
3583
+ ctx.findings.backend(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} changed the size of "${effect.entity}" while declaring ${effect.op}`, `x-effects declares ${effect.op}, which must not add or remove records, but the ` +
3584
+ `collection went from ${prior.length} to ${after.ids.length}`, [invoked, after.exchange], fixture);
3585
+ }
3586
+ });
3538
3587
  }
3539
3588
  },
3540
3589
  };
@@ -3564,6 +3613,7 @@ const sideEffectArrives = {
3564
3613
  const invoked = await ctx.client.request(op.method, fillPath(op.path, ctx.scope), {
3565
3614
  headers: ctx.auth(),
3566
3615
  operationId: op.operationId,
3616
+ ...(fixture === undefined ? {} : { fixture }),
3567
3617
  ...(body === undefined ? {} : await encodeOpBody(ctx, op, body, "baseline", 0, uploads)),
3568
3618
  });
3569
3619
  if (standDownForFeatureGate(ctx, op, invoked, this.id))
@@ -3574,13 +3624,14 @@ const sideEffectArrives = {
3574
3624
  ctx.findings.gap(this.id, subject(ctx.entityName, op.operationId, fixture), `${op.operationId} could not be invoked`, `returned ${invoked.status}; its declared x-wait is unverified`, fixture);
3575
3625
  return;
3576
3626
  }
3627
+ const scope = bindWaitScope(ctx, op, pollOp, invoked.responseBody);
3577
3628
  const outcome = await driveWait({
3578
3629
  awaitSideEffect: ctx.hooks.awaitSideEffect,
3579
3630
  client: ctx.client,
3580
3631
  headers: ctx.auth,
3581
3632
  pollOp,
3582
3633
  record: invoked.responseBody,
3583
- scope: ctx.scope,
3634
+ scope,
3584
3635
  spec,
3585
3636
  writeOpId: op.operationId,
3586
3637
  ...(ctx.refreshIfStale === undefined ? {} : { refreshIfStale: ctx.refreshIfStale }),
@@ -3596,23 +3647,34 @@ const sideEffectArrives = {
3596
3647
  }
3597
3648
  },
3598
3649
  };
3599
- async function observe(ctx, listOp) {
3650
+ function listOpFor(ctx, entityName) {
3651
+ const listId = ctx.model.entities.get(entityName)?.list;
3652
+ return listId === undefined ? undefined : ctx.model.byOperationId.get(listId);
3653
+ }
3654
+ function bindWaitScope(ctx, writeOp, pollOp, writeBody) {
3655
+ const scope = { ...ctx.scope };
3656
+ Object.assign(scope, bindAfterCreateEffects(ctx.model, writeOp.effects, writeBody));
3657
+ Object.assign(scope, bindMissingPathParams(pollOp.pathParams, scope, writeBody));
3658
+ return scope;
3659
+ }
3660
+ async function observe(ctx, listOp, scope = ctx.scope) {
3600
3661
  let path;
3601
3662
  try {
3602
- path = fillPath(listOp.path, ctx.scope);
3663
+ path = fillPath(listOp.path, scope);
3603
3664
  }
3604
3665
  catch {
3605
- return null;
3666
+ return { status: "unfillable" };
3606
3667
  }
3607
3668
  const exchange = await ctx.client.get(path, {
3608
3669
  headers: ctx.auth(),
3670
+ operationId: listOp.operationId,
3609
3671
  query: { limit: listOp.query?.maxLimit ?? 100 },
3610
3672
  });
3611
3673
  if (exchange.status >= 400)
3612
- return null;
3674
+ return { exchange, status: "error" };
3613
3675
  const identity = ctx.model.entities.get(listOp.entity ?? "")?.identity ?? "id";
3614
3676
  const items = extractItems(exchange.responseBody, listOp.collection?.key ?? null);
3615
- return { exchange, ids: items.map((item) => String(item[identity])) };
3677
+ return { exchange, ids: items.map((item) => String(item[identity])), status: "ok" };
3616
3678
  }
3617
3679
  const asyncReachesTerminalState = {
3618
3680
  applicable: (ctx) => ctx.asyncOps.length > 0,