Skip to content

Commit 06dbc4e

Browse files
fix(verify)!: runDashboardWindowProofs takes no options, a run crossing UTC midnight reads not-run, and four proof families share one set of fixture helpers (#15953 slice 6) (#23236)
Part of #15953 Clause-②: no (narrowing) ## What this is Slice 6 of #15953 adds no new proof family. It does three things in `packages/verify`: - it carries the two notes slice 5's contract review owed on the next push to `dashboard-window.ts` (record `6138334190` on PR #23150); - it removes `DashboardWindowProofOptions.now`, as the claim (`6140521245`) ruled; - it moves the fixture helpers that four families copied privately into one home, `derive.ts`. **Why the removal is declared breaking.** The version commit `d889448b93` (#21988) landed after the claim and consumed slice 5's changeset. It published `@objectstack/verify@18.0.0-next.0` to dist-tag `next`, and that version carries the option. The dev therefore stopped and asked (report `6141945855`). The seat ruled **A2** (`6142005900`): the removal stands, declared as a narrowing of the published prerelease. The consumed note under `.changeset/pre/` is restored byte for byte, and one new changeset carries the narrowing and the fix. Per that ruling, a `CONTRACT_REVIEW_TIER` review is owed before the queue. ## What changed - **`runDashboardWindowProofs(stack, token, config)`.** The fourth parameter is gone, and so is its type `DashboardWindowProofOptions`, whose only member was `now`. - The server resolves a dashboard's date preset per request on its own clock, so any `now` other than the current instant made every window read `contradicted`. - Nothing in the tree passed it, and neither does hotcrm at `1d7148bf`, which pins `^17.7.0`. - The presets now resolve once, when the run starts, on `new Date()`, and the function's docblock says so. - **The day-turn guard.** `const startDay = todayAt(start)` sits beside the presets' resolution. In `probeMeasure`, directly after `const day0 = todayAt(new Date())`, a measure with `day0 !== startDay` is `not-run` and writes no fixture. Its detail reads "the day turned after the run started (START → DAY0), so the windows the presets resolved then are not the ones the server resolves now". - Before this, a run that crossed UTC midnight between `start` and a measure's probe judged that measure against the previous day's edges, and could read it `contradicted`: a false red, never a false green. - The end-of-probe guard, "the day turned during the probe", is unchanged. - **`derive.ts` is the one home for the shared fixture helpers:** `laterBy`, `vary(base, k, tag)`, `uniqueFields` and `fixtureBody(body, objectDef, n, tag, full = false)`. - They are exported from that module for the families only, and ⛔ not from `index.ts`. - `unique-scope`, `parent-write-reach` and `dashboard-window` import them, each passing its own `FIXTURE_TAG` (`'u'`, `'p'`, `'w'`). - `delete-behavior` imports `uniqueFields` and `laterBy`, and keeps its own `fixtureBody`. The reason is in the next section. - **`index.ts`** drops `type DashboardWindowProofOptions`. - **`README.md`**: the API line reads `runDashboardWindowProofs(stack, token, config)`, and the `inconclusive` row says "the UTC day turned after the run started". - **Changesets:** - `.changeset/15953-verify-dashboard-window-now-removed.md` is new: `@objectstack/verify` `minor`, a `fix(verify)!:` summary, `Clause-②: no (narrowing)`, one ADR-0087 marker, the BREAKING line, the FROM → TO line and a fix paragraph for the guard. - `.changeset/pre/15953-verify-dashboard-window-family.md` is unchanged from `main`: the edit an earlier push made to it is reverted, and its blob equals `main`'s, `144d744b53`. ## The helper measurement Each top-level function's text was extracted verbatim from `git show d4aaeaf:packages/verify/src/FAMILY.ts` and hashed with sha1 (first 10 hex digits). | helper | unique-scope | parent-write-reach | dashboard-window | delete-behavior | disposition | |---|---|---|---|---|---| | `uniqueFields` | `85f6a84965` | `85f6a84965` | `85f6a84965` | `85f6a84965` | moved; all four import it | | `laterBy` | `f48197955d` | `f48197955d` | `f48197955d` | `9d2c4c7538` (takes `unknown`) | moved as one function (see below) | | `vary` | `a1664fcca2` | `78966f349c` | `6b2779c503` | none | moved as `vary(base, k, tag)`; all three equal `076d9a28df` once the tag letter is normalised | | `fixtureBody` | `69df6c1389` | `69df6c1389` | `32dccc53e0` | `e9e1202f4e` | moved for the three; equals `69df6c1389` once `full = false` is taken out. delete-behavior's **stays private** | - **`laterBy`.** The string and `unknown` copies agree on every string input. The shared one is delete-behavior's body byte for byte (body sha `1a71ae0923` at base and in `derive.ts`), with two overloads: string to string, and unknown to unknown. - **Why delete-behavior's `fixtureBody` stays private.** It makes a unique string distinct with a plain `-n` (before the `@` of an email), with no family tag and no date, URL or phone branch. It also advances its date offset for a date value that is not a string. No parameter carries that without changing a value this family writes, so it stays where it is, and its docblock says why. - **The shared `uniqueFields` body** is `dd7aa50492` in all four copies and in `derive.ts`. ## Byte identity: method and result 1. **Capture.** On `d4aaeaf0e2`, an uncommitted, env-gated (`OS_TEST_CAPTURE_FIXTURES`) append-only shim wrapped every private helper in the four families. The families' own five test files then ran with it, and it logged each call's arguments and return value: 1,984 calls, 0 capture errors, 5 files / 49 tests passed. - unique-scope: `fixtureBody` 218, `uniqueFields` 218, `vary` 266. - parent-write-reach: `fixtureBody` 197, `uniqueFields` 197. - dashboard-window: `fixtureBody` 322, `uniqueFields` 322, `laterBy` 40. - delete-behavior: `fixtureBody` 102, `uniqueFields` 102. The shim was restored with `git checkout HEAD --` on absolute paths. The restore was proven by each file's blob equalling HEAD's and by an empty `git diff HEAD`. 2. **Differential.** A tsx script outside the repo called three implementations on every captured call, plus 1,455 synthetic calls that walk every branch: - OLD: each family's private copy, extracted verbatim from `d4aaeaf0e2`; - NEW: `derive.ts`'s helper with the family's tag. For delete-behavior, its still-private `fixtureBody`, taken from the working tree and bound to the shared helpers; - CAPTURED: what the private copy returned in the family's own run. The synthetic calls cover email, URL with and without a trailing slash, phone, date, datetime, unparsable values, numbers, `k = 0`, `days = 0`, non-strings, unique scopes and indexes, and `full`. Each output is serialised so that `undefined` and `Set` stay distinguishable. 3. **Result: PASS.** All 3,439 records were byte-identical, NEW to OLD and NEW to CAPTURED. Report fields carry only these values plus run-dependent ids. 4. **Control: FAIL.** Running the same script with unique-scope's tag set to `x` gave 860 mismatches, the first `verify-sample-u1` against `verify-sample-x1`. 5. **Families' own suites.** At `59507b26a3`, the five family files plus `derive.test.ts` gave 6 files / 76 tests passed: 50 in the family files (the 49 above plus the new day-turn pin) and 26 in `derive.test.ts` (22 plus 4 new pins). ## `dist/index.d.ts` diff This diff compares the **published** `@objectstack/verify@18.0.0-next.0` tarball's `dist/index.d.ts` with this branch's build at `75877cc2e2`. It shows four hunks and nothing else (the same four as a pristine `d4aaeaf0e2` build against `59507b26a3`): ```diff -interface DashboardWindowProofOptions { - /** The reference instant the ground truth resolves on. Defaults to the start of the run. */ - now?: Date; -} + * + * The presets resolve once, when the run starts, on the current instant: the + * server resolves them per request on its own clock, so no other instant + * could agree with it. A measure whose fixture day is no longer the day the + * run started on is not run. -declare function runDashboardWindowProofs(stack: VerifyStack, token: string, config: any, options?: DashboardWindowProofOptions): … +declare function runDashboardWindowProofs(stack: VerifyStack, token: string, config: any): … ``` The return type is unchanged. The export list differs only by `type DashboardWindowProofOptions, `. `laterBy`, `vary`, `uniqueFields` and `fixtureBody` have 0 hits in `dist/index.d.ts`. `derive.ts` imports only `@objectstack/spec/data` and `./artifact-collections.js`, so there is no import cycle, and `index.ts` re-exports it by name. ## The day-turn guard: pin and ablation **Pin.** `dashboard-window.test.ts` › "THE DAY TURN" runs a one-widget app (`wnt_case.opened_at`, one count measure). - The module reads the clock only through `new Date()`: once at `start`, then per measure for `day0` and for the end guard. - So after boot and sign-in, the test calls `vi.useFakeTimers({ toFake: ['Date'] })`. Only `Date` is faked, frozen at the real instant, and every timer stays real. - The run gets a copy of the stack whose `apiAs`, on its first call, does `vi.setSystemTime(Date.now() + DAY_MS)`. That first call is the measure's control leg, which falls between the two clock reads. `vi.useRealTimers()` runs in `finally`. - No public option was added. - The pin asserts: - the widget reads `inconclusive`; - all 13 outcomes are `not-run`; - `days` is undefined, so no fixture was written; - the detail names the start day and the next day; - the summary has `contradictions: 0`, `hardFailures: 0` and `notRun: 13`. **Ablation (a)**, at `59507b26a3`, through `scripts/ablation-replace.mjs` in WRAP mode with its own restore trap: - The guard block was deleted. The anchor count went from 1 to 0, and the blob from `2bdb9b1ca1a7` to `616f4a616d95`. - **RED**: 1 failed, 10 skipped; expected `inconclusive`, received `contradicted`. The report read: - `last_7_days`: "the window counted 4 fixture row(s), and it selects 5 (narrower)"; - `last_30_days`: 9 against 10; - `last_90_days`: 13 against 14. - Restored: blob equal to HEAD, and `git diff HEAD` empty. - The test imports `./dashboard-window.js` relatively, so vitest reads `src` and no dist build is in the path. ## `vary`'s tag: pin and ablation **Pin.** `derive.test.ts` › "the fixture helpers the proof families share" covers: - `vary` marking a string with each family's letter (`Acme-u3`, `Acme-p3`, `Acme-w3`, `ada-u2@example.com`, `https://example.com/p1`); - a number, a date and a phone number taking `k` alone; - `laterBy` on date and datetime spellings and on non-strings; - the fields `uniqueFields` returns; - `fixtureBody`'s smallest and `full` shapes under two tags. **Ablation (b)**, at `59507b26a3`: - The first pass was a **no-op**: its replacement contained its own anchor, so the tool refused it and restored, and its reading is void. - The second pass replaced the parameter `tag` with a renamed one plus `const tag = 'u'`. The anchor count went from 1 to 0, and the blob from `655a802e3813` to `201cb8a6d1df`. - **RED**: 2 failed, 2 passed, 22 skipped of 26. `vary` expected `Acme-p3` and received `Acme-u3`, and the `fixtureBody` pin failed with tag `w`. - Restored: blob equal to HEAD, and `git diff HEAD` empty. ## The changeset **The ADR-0087 marker** is `not-required (runtime-export-retired @objectstack/verify#DashboardWindowProofOptions)`. `node scripts/check-adr-0087-registration.mjs --base origin/main` verifies it as an export of the published entry at the merge base that is absent at HEAD. **The FROM → TO line names the retired export.** Its TO side carries the call change: `runDashboardWindowProofs(stack, token, config, { now })` becomes `runDashboardWindowProofs(stack, token, config)`, and the fix is to drop the fourth argument. The ruling's literal shape, a FROM line on the four-argument call, was measured and refused: - the gate's predicate 3 reads `runDashboardWindowProofs` as the FROM operand, which is out of range because the marker does not name it; - naming it would fail predicate 1, because the function is still exported; - the probe was an uncommitted edit, restored and proven by the file's blob equalling HEAD's. ## Verification At **`75877cc2e2`**, the merged head (`origin/main` `76815b9013`, which carries #23179). `main` was merged twice; the second merge followed #23179's landing, and everything below was re-run on its tree: - **Builds:** - `turbo run build --filter='!@objectstack/docs' --concurrency=2` (the full build): 74/74, exit 0. - **Tests:** - `pnpm --filter @objectstack/verify test`: 38 files / 306 tests passed, exit 0. - core's `#22661` pin, `pnpm --filter @objectstack/core exec vitest run --project repo --maxWorkers=2 src/security/second-object-read-exposure.pin.test.ts`: 1 file / 4 tests, exit 0. - **Typechecks:** - `pnpm --filter @objectstack/verify typecheck`: exit 0. - `pnpm --filter @objectstack/cli typecheck`: exit 0. - **Changeset gates:** - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0, "No changeset from the merge base modified or deleted by this diff". - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition". - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0, "This diff introduces no `major` bump". - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths gave 61 commands, run with exit codes captured before any pipe, plus `pnpm check:verify-stand-in`. - All 62 exited 0, including `check:dual-build-cjs-loads` after the full build: "108 published require entry point(s) across 67 package(s) load". - `check:verify-stand-in`: "2 stand-in check(s) guarded, 17 candidate(s) explicitly exempt, 10 call site(s) reached". - `check:nul-bytes`: OK. - `dispatch-gates --ran` with every line exit-coded reconciled 61 derived, 61 run, 0 NOT-MEASURED and 0 UNRUN. `check:verify-stand-in` lies outside the derivation. - **Lint**, a proven narrowing over the touched files: 1. The population comes from `eslint.config.mjs`'s code-extension `files` globs: the 8 touched `.ts` files are linted, and the 2 touched `.md` files (README and the new changeset) answer "File ignored because no matching configuration was supplied". 2. eslint `--no-inline-config --format json` reports 8 files / 0 errors / 0 warnings. 3. The config enables no type-aware linting (no `parserOptions.project`) and no cross-file rule, so this diff cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's. ## Acceptance notes - **delete-behavior's private `fixtureBody`** gives a unique required date, URL or phone value a plain `-n` suffix (`2026-10-13-1` is not a date). An app with such a field would get that family's fixture refused, which reports `not-run`, never a false verdict. This is a reading with no repro, carried and not filed (ruling `6142005900`), and it is why that copy stays private. - **The 18.0.0-next.0 `CHANGELOG.md` entry** still shows `{ now? }`. It is release-owned, and the new changeset is the next version's record of the removal. - **#23179**, which edits `parent-write-reach.test.ts`, landed while this slice ran. It is merged in, and verify's suite was re-run on the merged tree (38 files / 306 tests). This PR does not touch that file. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78c13bd commit 06dbc4e

10 files changed

Lines changed: 294 additions & 206 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/verify': minor
3+
---
4+
5+
fix(verify)!: `runDashboardWindowProofs` takes no options, so the dashboard date-window run can no longer be pinned to an instant the server does not resolve on, and a run that crosses UTC midnight after it starts reports `not-run` instead of a false red
6+
7+
`runDashboardWindowProofs(stack, token, config)` loses its fourth parameter, and `DashboardWindowProofOptions` (its only member was `now`) is retired. The server resolves a dashboard's date preset per request on its own clock, so any `now` other than the current instant made every window read `contradicted`: the option could only produce false reds, and nothing passed it.
8+
9+
Clause-②: no (narrowing)
10+
11+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/verify#DashboardWindowProofOptions) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What retires is one type export of `@objectstack/verify`'s published entry, `DashboardWindowProofOptions`, together with the optional fourth parameter of `runDashboardWindowProofs` that it typed. The affected party is a TypeScript caller, and the compiler at that call site delivers the break. -->
12+
13+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
- FROM `DashboardWindowProofOptions` TO nothing (no replacement): drop the fourth argument, so `runDashboardWindowProofs(stack, token, config, { now })` becomes `runDashboardWindowProofs(stack, token, config)`.
16+
17+
Fix: the presets resolve when the run starts, and each count measure places its fixture rows on the day it is probed. A run that crossed UTC midnight between the two judged that measure against the previous day's edges and could read it `contradicted`. That measure is now `not-run`, and its detail names both days; the widget reads `inconclusive`. The guard that catches a day turning while a measure is being probed is unchanged.

‎packages/verify/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,7 @@ fixture rows:
500500
| `proven` | under every preset the window counted exactly the fixture rows it selects |
501501
| `contradicted` | under some preset the widget showed nothing where the window selects fixture rows (`blank`), or counted another number of them; a window that answers what no window answers under every preset is also named `inert` **(failure)** |
502502
| `failed` | the door refused or faulted on a windowed query while the widget's own query answered **(failure)** |
503-
| `inconclusive` | a fixture row could not be written or did not read back as written (an app hook rewrote it, or the read omits the column), the widget with no window did not count every fixture row (its own selection does not meet the fixtures), its own query is refused, the day turned mid-probe, or the caller's reference timezone is not UTC |
503+
| `inconclusive` | a fixture row could not be written or did not read back as written (an app hook rewrote it, or the read omits the column), the widget with no window did not count every fixture row (its own selection does not meet the fixtures), its own query is refused, the UTC day turned after the run started, or the caller's reference timezone is not UTC |
504504
| `unbound` | the widget opts out of the window (`filterBindings: { dateRange: false }`) |
505505
| `excluded` | the family cannot compare it: no count measure, a filter the ground truth cannot model, a window on a field that is missing or is not `date` / `datetime`, a dataset or dashboard that is undeclared or does not parse, or an object the app does not declare or the CRUD derivation cannot synthesize (`excludedKind` names which) |
506506

@@ -556,7 +556,7 @@ run" must never read like "nothing to find".
556556
a gate reads); `formatParentWriteReachReport(report)`.
557557
- `deriveDashboardWindowCases(config)` → one case per widget of every dashboard with a `dateRange`: its bound field,
558558
dataset, object and count measures, or why it is unbound or not compared; `runDashboardWindowProofs(stack, token,
559-
config, { now? })` → `DashboardWindowReport` (`presets` names what each preset handed the door and how it resolved;
559+
config)` → `DashboardWindowReport` (`presets` names what each preset handed the door and how it resolved;
560560
`summary.hardFailures` is what a gate reads); `formatDashboardWindowReport(report)`.
561561

562562
`bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin`

‎packages/verify/src/dashboard-window.test.ts‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
// found, and the first is named inert;
1212
// • the ATTRIBUTION control: an app hook that rewrites the window's field on
1313
// write makes the probe not run, never contradicted — the family judges a
14-
// window only over rows that read back as written.
14+
// window only over rows that read back as written;
15+
// • the DAY TURN: a run that crosses UTC midnight between its start (where
16+
// the presets resolve) and a measure's probe does not run that measure,
17+
// never contradicts it.
1518

16-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
19+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
1720

1821
import { defineStack } from '@objectstack/spec';
1922
import { DATE_RANGE_PRESETS } from '@objectstack/spec/data';
@@ -371,3 +374,76 @@ describe.each(['drop', 'zero'] as const)("NEGATIVE CONTROL — an app plugin tha
371374
expect(report.summary.widgets).toMatchObject({ contradicted: 1, inconclusive: 2, proven: 0 });
372375
});
373376
});
377+
378+
// ── The day turn ────────────────────────────────────────────────────────────
379+
380+
const dayTurnSource = {
381+
manifest: { id: 'com.example.dashboard-window-day-turn', namespace: 'wnt', version: '0.0.1', type: 'app', name: 'Dashboard Window Day Turn' },
382+
objects: [
383+
{ name: 'wnt_case', label: 'Case', fields: { subject: { type: 'text', label: 'Subject', required: true }, opened_at: { type: 'datetime', label: 'Opened at' } } },
384+
],
385+
datasets: [{ name: 'turn_metrics', label: 'Cases', object: 'wnt_case', dimensions: [], measures: [{ name: 'case_count', label: 'Cases', aggregate: 'count' }] }],
386+
dashboards: [
387+
{
388+
name: 'wnt_board',
389+
label: 'Board',
390+
dateRange: { field: 'opened_at', defaultRange: 'last_7_days' },
391+
widgets: [{ id: 'cases', type: 'metric', dataset: 'turn_metrics', values: ['case_count'] }],
392+
},
393+
],
394+
};
395+
396+
const DAY_MS = 86_400_000;
397+
const nextDay = (day: string) => new Date(Date.parse(`${day}T00:00:00.000Z`) + DAY_MS).toISOString().slice(0, 10);
398+
399+
describe("THE DAY TURN — a run that crosses UTC midnight between its start and a measure's probe", () => {
400+
let stack: VerifyStack;
401+
let report: DashboardWindowReport;
402+
const app = defineStack(dayTurnSource as never) as unknown as typeof dayTurnSource;
403+
404+
beforeAll(async () => {
405+
stack = await bootStack(app as never);
406+
const token = await stack.signIn();
407+
// The family reads the clock through `Date` alone: once when the run
408+
// starts (the presets resolve there), then per measure for its fixture day.
409+
// So only `Date` is faked (every timer stays real), frozen at the real
410+
// instant, and moved one day on the run's first door call: the measure's
411+
// control leg, which falls between those two reads.
412+
vi.useFakeTimers({ toFake: ['Date'] });
413+
let turned = false;
414+
const turning: VerifyStack = {
415+
...stack,
416+
apiAs: (...args: Parameters<VerifyStack['apiAs']>) => {
417+
if (!turned) {
418+
turned = true;
419+
vi.setSystemTime(Date.now() + DAY_MS);
420+
}
421+
return stack.apiAs(...args);
422+
},
423+
};
424+
try {
425+
report = await runDashboardWindowProofs(turning, token, app);
426+
} finally {
427+
vi.useRealTimers();
428+
}
429+
}, BOOT_TIMEOUT + RUN_TIMEOUT);
430+
431+
afterAll(async () => {
432+
await stack?.stop();
433+
});
434+
435+
it('does not run the measure, never contradicts it, and writes no fixture', () => {
436+
const r = widgetOf(report, 'cases');
437+
expect(r.status, formatDashboardWindowReport(report)).toBe('inconclusive');
438+
const m = r.measures[0]!;
439+
expect(m.status).toBe('probed');
440+
expect(m.presets.map((p) => p.outcome)).toEqual(DATE_RANGE_PRESETS.map(() => 'not-run'));
441+
// Stopped before a fixture day was chosen: nothing was written.
442+
expect(m.days).toBeUndefined();
443+
// The detail names both days: the one the run started on, and the next.
444+
const startDay = report.presets.find((p) => p.preset === 'today')!.resolved.$gte as string;
445+
expect(m.presets[0]!.detail).toContain(startDay);
446+
expect(m.presets[0]!.detail).toContain(nextDay(startDay));
447+
expect(report.summary).toMatchObject({ contradictions: 0, hardFailures: 0, notRun: DATE_RANGE_PRESETS.length });
448+
});
449+
});

‎packages/verify/src/dashboard-window.ts‎

Lines changed: 19 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import { DashboardSchema, DatasetSchema } from '@objectstack/spec/ui';
6565
import { DATE_RANGE_PRESETS, DATE_RANGE_PRESET_MACRO_WINDOWS, type DateRangePreset } from '@objectstack/spec/data';
6666
import { resolveFilterTokens } from '@objectstack/core';
6767
import { declaredCollection } from './artifact-collections.js';
68-
import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js';
68+
import { deriveCrudCases, fillRelationalRefs, fixtureBody, type CrudCase } from './derive.js';
6969
import type { VerifyStack } from './harness.js';
7070

7171
/**
@@ -485,58 +485,8 @@ function shiftDay(day: string, n: number): string {
485485
return new Date(Date.parse(`${day}T00:00:00.000Z`) + n * DAY_MS).toISOString().slice(0, 10);
486486
}
487487

488-
/** A `date` / `datetime` value moved `days` later, in the same spelling. */
489-
function laterBy(value: string, days: number): string {
490-
const at = Date.parse(value);
491-
if (Number.isNaN(at) || days === 0) return value;
492-
const moved = new Date(at + days * DAY_MS).toISOString();
493-
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved;
494-
}
495-
496-
/** The `k`-th distinct value of a sample `base`. */
497-
function vary(base: string | number, k: number): string | number {
498-
if (typeof base === 'number') return base + k;
499-
if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k);
500-
const at = base.indexOf('@');
501-
if (at > 0) return `${base.slice(0, at)}-w${k}${base.slice(at)}`;
502-
if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/w${k}`;
503-
if (/^\+\d+$/.test(base)) return `${base}${k}`;
504-
return `${base}-w${k}`;
505-
}
506-
507-
/** The fields of `objectDef` a uniqueness constraint covers: a declared field scope, or a unique index entry. */
508-
function uniqueFields(objectDef: any): Set<string> {
509-
const out = new Set<string>();
510-
for (const [name, def] of Object.entries<any>(objectDef?.fields ?? {})) {
511-
if (def?.unique !== undefined && def?.unique !== false) out.add(name);
512-
}
513-
for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) {
514-
if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f));
515-
}
516-
return out;
517-
}
518-
519-
/**
520-
* A record the CRUD derivation can offer: by default the smallest (the shape
521-
* the sibling families use) — its values for the fields the object REQUIRES,
522-
* so every optional field takes the app's own default; with `full`, every
523-
* field it synthesizes (what a rule that requires a field only in some state
524-
* asks for). Each `date` / `datetime` is one day after the one declared before
525-
* it, and each unique-constrained value is made distinct by `n`.
526-
*/
527-
function fixtureBody(body: Record<string, unknown>, objectDef: any, n: number, full = false): Record<string, unknown> {
528-
const unique = uniqueFields(objectDef);
529-
const out: Record<string, unknown> = {};
530-
let dates = 0;
531-
for (const [name, def] of Object.entries<any>(objectDef?.fields ?? {})) {
532-
if ((!full && def?.required !== true) || !(name in body)) continue;
533-
let value = body[name];
534-
if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++);
535-
if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n);
536-
out[name] = value;
537-
}
538-
return out;
539-
}
488+
/** This family's letter on every string value it varies (`derive.ts`'s `vary`). */
489+
const FIXTURE_TAG = 'w';
540490

541491
/** The days the fixtures are written on: each preset's first and last day and the day either side, never after today. */
542492
function fixtureDays(presets: DashboardWindowPreset[], today: string): string[] {
@@ -592,11 +542,6 @@ function judge(
592542

593543
// ── The run ─────────────────────────────────────────────────────────────────
594544

595-
export interface DashboardWindowProofOptions {
596-
/** The reference instant the ground truth resolves on. Defaults to the start of the run. */
597-
now?: Date;
598-
}
599-
600545
/**
601546
* Run the dashboard date-window family against a live stack, as the principal
602547
* `token` signs in: for every widget a `dateRange` binds, every count measure
@@ -605,12 +550,16 @@ export interface DashboardWindowProofOptions {
605550
* known days, and each difference is compared with the rows that window
606551
* selects. Returns a structured report; a per-widget failure is collected,
607552
* not thrown.
553+
*
554+
* The presets resolve once, when the run starts, on the current instant: the
555+
* server resolves them per request on its own clock, so no other instant
556+
* could agree with it. A measure whose fixture day is no longer the day the
557+
* run started on is not run.
608558
*/
609559
export async function runDashboardWindowProofs(
610560
stack: VerifyStack,
611561
token: string,
612562
config: any,
613-
options: DashboardWindowProofOptions = {},
614563
): Promise<DashboardWindowReport> {
615564
const { cases, dashboards } = derive(config);
616565
const crud = new Map<string, CrudCase>(deriveCrudCases(config).map((c) => [c.object, c]));
@@ -623,13 +572,14 @@ export async function runDashboardWindowProofs(
623572
const utc = timezone === undefined || timezone === 'UTC';
624573
const tokenCtx = (now: Date) => ({ now, timezone, userId: principal });
625574

626-
const start = options.now ?? new Date();
575+
const start = new Date();
627576
const presets: DashboardWindowPreset[] = DATE_RANGE_PRESETS.map((preset) => {
628577
const [from, to] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset];
629578
const condition: Record<string, string> = { $gte: from, ...(to ? { $lte: to } : {}) };
630579
return { preset, condition, resolved: resolveFilterTokens(condition, tokenCtx(start)) as Record<string, unknown> };
631580
});
632581
const todayAt = (now: Date) => String(resolveFilterTokens('{today}', tokenCtx(now)));
582+
const startDay = todayAt(start);
633583

634584
const registered = (object: string): Record<string, any> => {
635585
try {
@@ -687,7 +637,7 @@ export async function runDashboardWindowProofs(
687637
} else {
688638
const { body } = fillRelationalRefs(c, ids);
689639
try {
690-
const [written] = await stack.seed(object, [fixtureBody(body, objectDefs.get(object), ++counter)]);
640+
const [written] = await stack.seed(object, [fixtureBody(body, objectDefs.get(object), ++counter, FIXTURE_TAG)]);
691641
made = written?.id != null ? { id: String(written.id) } : { reason: `the seed of ${object} returned no id` };
692642
} catch (e) {
693643
made = { reason: `the seed of ${object} was refused: ${clip((e as Error)?.message, 160)}` };
@@ -843,6 +793,13 @@ export async function runDashboardWindowProofs(
843793
return;
844794
}
845795
const day0 = todayAt(new Date());
796+
// The presets resolved when the run started; the server resolves them on
797+
// today. Once the day has turned since then, the ground truth would judge
798+
// this measure against yesterday's edges, so it is not run.
799+
if (day0 !== startDay) {
800+
notRun(`the day turned after the run started (${startDay} → ${day0}), so the windows the presets resolved then are not the ones the server resolves now`);
801+
return;
802+
}
846803
const before = new Map<DateRangePreset, DashboardWindowAnswer>();
847804
for (const p of presets) before.set(p.preset, await ask(c.dataset!, selection(p.condition), m.measure));
848805

@@ -869,7 +826,7 @@ export async function runDashboardWindowProofs(
869826
let full = false;
870827
for (const day of days) {
871828
const row = (wide: boolean): Record<string, unknown> => ({
872-
...fixtureBody(body, objectDefs.get(c.object!), ++counter, wide),
829+
...fixtureBody(body, objectDefs.get(c.object!), ++counter, FIXTURE_TAG, wide),
873830
...(ownerColumn ? { owner_id: principal } : {}),
874831
...Object.fromEntries(held),
875832
[c.field!]: stamp(day),

‎packages/verify/src/delete-behavior.ts‎

Lines changed: 7 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565

6666
import { FieldSchema, REFERENCE_VALUE_TYPES, isMultiValueField, referenceTargetOf } from '@objectstack/spec/data';
6767
import { declaredCollection } from './artifact-collections.js';
68-
import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js';
68+
import { deriveCrudCases, fillRelationalRefs, laterBy, uniqueFields, type CrudCase } from './derive.js';
6969
import type { VerifyStack } from './harness.js';
7070

7171
/** A behaviour a declaration can state (`FieldSchema.deleteBehavior`). */
@@ -259,32 +259,6 @@ function references(value: unknown, id: string): boolean {
259259
return value != null && String(value) === id;
260260
}
261261

262-
/**
263-
* The fields of `objectDef` a uniqueness constraint covers: a field declaring a
264-
* `unique` scope, and every field of a unique `indexes[]` entry.
265-
*/
266-
function uniqueFields(objectDef: any): Set<string> {
267-
const out = new Set<string>();
268-
for (const [name, def] of Object.entries<any>(objectDef?.fields ?? {})) {
269-
if (def?.unique !== undefined && def?.unique !== false) out.add(name);
270-
}
271-
for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) {
272-
if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f));
273-
}
274-
return out;
275-
}
276-
277-
const DAY_MS = 86_400_000;
278-
279-
/** A synthesized `date` / `datetime` value moved `days` later, in the same spelling. */
280-
function laterBy(value: unknown, days: number): unknown {
281-
if (typeof value !== 'string' || days === 0) return value;
282-
const at = Date.parse(value);
283-
if (Number.isNaN(at)) return value;
284-
const moved = new Date(at + days * DAY_MS).toISOString();
285-
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved;
286-
}
287-
288262
/**
289263
* The smallest record of `objectDef` the CRUD derivation can offer: the values
290264
* `deriveCrudCases` synthesized for the fields the object REQUIRES (its
@@ -295,6 +269,12 @@ function laterBy(value: unknown, days: number): unknown {
295269
* delete: each `date` / `datetime` value is one day later than the one declared
296270
* before it (a "starts before it ends" rule holds), and each unique-constrained
297271
* value is made distinct by `n`.
272+
*
273+
* This is not `derive.ts`'s `fixtureBody`, which the other families share, and
274+
* no parameter could make it one without changing a value this family writes:
275+
* here a unique string always takes a plain `-n` (an email before its `@`), a
276+
* date, URL or phone number included, and every `date` / `datetime` field
277+
* counts toward the next one's offset, whether its value is a string or not.
298278
*/
299279
function fixtureBody(body: Record<string, unknown>, objectDef: any, n: number): Record<string, unknown> {
300280
const unique = uniqueFields(objectDef);

0 commit comments

Comments
 (0)