diff --git a/.changeset/15953-verify-dashboard-window-now-removed.md b/.changeset/15953-verify-dashboard-window-now-removed.md new file mode 100644 index 00000000000..625aa1e5cb7 --- /dev/null +++ b/.changeset/15953-verify-dashboard-window-now-removed.md @@ -0,0 +1,17 @@ +--- +'@objectstack/verify': minor +--- + +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 + +`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. + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +- FROM `DashboardWindowProofOptions` TO nothing (no replacement): drop the fourth argument, so `runDashboardWindowProofs(stack, token, config, { now })` becomes `runDashboardWindowProofs(stack, token, config)`. + +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. diff --git a/packages/verify/README.md b/packages/verify/README.md index e25900eba2e..f39e675995c 100644 --- a/packages/verify/README.md +++ b/packages/verify/README.md @@ -500,7 +500,7 @@ fixture rows: | `proven` | under every preset the window counted exactly the fixture rows it selects | | `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)** | | `failed` | the door refused or faulted on a windowed query while the widget's own query answered **(failure)** | -| `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 | +| `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 | | `unbound` | the widget opts out of the window (`filterBindings: { dateRange: false }`) | | `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) | @@ -556,7 +556,7 @@ run" must never read like "nothing to find". a gate reads); `formatParentWriteReachReport(report)`. - `deriveDashboardWindowCases(config)` → one case per widget of every dashboard with a `dateRange`: its bound field, dataset, object and count measures, or why it is unbound or not compared; `runDashboardWindowProofs(stack, token, - config, { now? })` → `DashboardWindowReport` (`presets` names what each preset handed the door and how it resolved; + config)` → `DashboardWindowReport` (`presets` names what each preset handed the door and how it resolved; `summary.hardFailures` is what a gate reads); `formatDashboardWindowReport(report)`. `bootStack` options: `admin`, `authSecret`, `security` (a custom `SecurityPlugin` diff --git a/packages/verify/src/dashboard-window.test.ts b/packages/verify/src/dashboard-window.test.ts index 024724c1dd5..8ba397b22d9 100644 --- a/packages/verify/src/dashboard-window.test.ts +++ b/packages/verify/src/dashboard-window.test.ts @@ -11,9 +11,12 @@ // found, and the first is named inert; // • the ATTRIBUTION control: an app hook that rewrites the window's field on // write makes the probe not run, never contradicted — the family judges a -// window only over rows that read back as written. +// window only over rows that read back as written; +// • the DAY TURN: a run that crosses UTC midnight between its start (where +// the presets resolve) and a measure's probe does not run that measure, +// never contradicts it. -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { defineStack } from '@objectstack/spec'; import { DATE_RANGE_PRESETS } from '@objectstack/spec/data'; @@ -371,3 +374,76 @@ describe.each(['drop', 'zero'] as const)("NEGATIVE CONTROL — an app plugin tha expect(report.summary.widgets).toMatchObject({ contradicted: 1, inconclusive: 2, proven: 0 }); }); }); + +// ── The day turn ──────────────────────────────────────────────────────────── + +const dayTurnSource = { + manifest: { id: 'com.example.dashboard-window-day-turn', namespace: 'wnt', version: '0.0.1', type: 'app', name: 'Dashboard Window Day Turn' }, + objects: [ + { name: 'wnt_case', label: 'Case', fields: { subject: { type: 'text', label: 'Subject', required: true }, opened_at: { type: 'datetime', label: 'Opened at' } } }, + ], + datasets: [{ name: 'turn_metrics', label: 'Cases', object: 'wnt_case', dimensions: [], measures: [{ name: 'case_count', label: 'Cases', aggregate: 'count' }] }], + dashboards: [ + { + name: 'wnt_board', + label: 'Board', + dateRange: { field: 'opened_at', defaultRange: 'last_7_days' }, + widgets: [{ id: 'cases', type: 'metric', dataset: 'turn_metrics', values: ['case_count'] }], + }, + ], +}; + +const DAY_MS = 86_400_000; +const nextDay = (day: string) => new Date(Date.parse(`${day}T00:00:00.000Z`) + DAY_MS).toISOString().slice(0, 10); + +describe("THE DAY TURN — a run that crosses UTC midnight between its start and a measure's probe", () => { + let stack: VerifyStack; + let report: DashboardWindowReport; + const app = defineStack(dayTurnSource as never) as unknown as typeof dayTurnSource; + + beforeAll(async () => { + stack = await bootStack(app as never); + const token = await stack.signIn(); + // The family reads the clock through `Date` alone: once when the run + // starts (the presets resolve there), then per measure for its fixture day. + // So only `Date` is faked (every timer stays real), frozen at the real + // instant, and moved one day on the run's first door call: the measure's + // control leg, which falls between those two reads. + vi.useFakeTimers({ toFake: ['Date'] }); + let turned = false; + const turning: VerifyStack = { + ...stack, + apiAs: (...args: Parameters) => { + if (!turned) { + turned = true; + vi.setSystemTime(Date.now() + DAY_MS); + } + return stack.apiAs(...args); + }, + }; + try { + report = await runDashboardWindowProofs(turning, token, app); + } finally { + vi.useRealTimers(); + } + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + it('does not run the measure, never contradicts it, and writes no fixture', () => { + const r = widgetOf(report, 'cases'); + expect(r.status, formatDashboardWindowReport(report)).toBe('inconclusive'); + const m = r.measures[0]!; + expect(m.status).toBe('probed'); + expect(m.presets.map((p) => p.outcome)).toEqual(DATE_RANGE_PRESETS.map(() => 'not-run')); + // Stopped before a fixture day was chosen: nothing was written. + expect(m.days).toBeUndefined(); + // The detail names both days: the one the run started on, and the next. + const startDay = report.presets.find((p) => p.preset === 'today')!.resolved.$gte as string; + expect(m.presets[0]!.detail).toContain(startDay); + expect(m.presets[0]!.detail).toContain(nextDay(startDay)); + expect(report.summary).toMatchObject({ contradictions: 0, hardFailures: 0, notRun: DATE_RANGE_PRESETS.length }); + }); +}); diff --git a/packages/verify/src/dashboard-window.ts b/packages/verify/src/dashboard-window.ts index 36251c048f3..2bdb9b1ca1a 100644 --- a/packages/verify/src/dashboard-window.ts +++ b/packages/verify/src/dashboard-window.ts @@ -65,7 +65,7 @@ import { DashboardSchema, DatasetSchema } from '@objectstack/spec/ui'; import { DATE_RANGE_PRESETS, DATE_RANGE_PRESET_MACRO_WINDOWS, type DateRangePreset } from '@objectstack/spec/data'; import { resolveFilterTokens } from '@objectstack/core'; import { declaredCollection } from './artifact-collections.js'; -import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import { deriveCrudCases, fillRelationalRefs, fixtureBody, type CrudCase } from './derive.js'; import type { VerifyStack } from './harness.js'; /** @@ -485,58 +485,8 @@ function shiftDay(day: string, n: number): string { return new Date(Date.parse(`${day}T00:00:00.000Z`) + n * DAY_MS).toISOString().slice(0, 10); } -/** A `date` / `datetime` value moved `days` later, in the same spelling. */ -function laterBy(value: string, days: number): string { - const at = Date.parse(value); - if (Number.isNaN(at) || days === 0) return value; - const moved = new Date(at + days * DAY_MS).toISOString(); - return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; -} - -/** The `k`-th distinct value of a sample `base`. */ -function vary(base: string | number, k: number): string | number { - if (typeof base === 'number') return base + k; - if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k); - const at = base.indexOf('@'); - if (at > 0) return `${base.slice(0, at)}-w${k}${base.slice(at)}`; - if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/w${k}`; - if (/^\+\d+$/.test(base)) return `${base}${k}`; - return `${base}-w${k}`; -} - -/** The fields of `objectDef` a uniqueness constraint covers: a declared field scope, or a unique index entry. */ -function uniqueFields(objectDef: any): Set { - const out = new Set(); - for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { - if (def?.unique !== undefined && def?.unique !== false) out.add(name); - } - for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { - if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); - } - return out; -} - -/** - * A record the CRUD derivation can offer: by default the smallest (the shape - * the sibling families use) — its values for the fields the object REQUIRES, - * so every optional field takes the app's own default; with `full`, every - * field it synthesizes (what a rule that requires a field only in some state - * asks for). Each `date` / `datetime` is one day after the one declared before - * it, and each unique-constrained value is made distinct by `n`. - */ -function fixtureBody(body: Record, objectDef: any, n: number, full = false): Record { - const unique = uniqueFields(objectDef); - const out: Record = {}; - let dates = 0; - for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { - if ((!full && def?.required !== true) || !(name in body)) continue; - let value = body[name]; - if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++); - if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n); - out[name] = value; - } - return out; -} +/** This family's letter on every string value it varies (`derive.ts`'s `vary`). */ +const FIXTURE_TAG = 'w'; /** The days the fixtures are written on: each preset's first and last day and the day either side, never after today. */ function fixtureDays(presets: DashboardWindowPreset[], today: string): string[] { @@ -592,11 +542,6 @@ function judge( // ── The run ───────────────────────────────────────────────────────────────── -export interface DashboardWindowProofOptions { - /** The reference instant the ground truth resolves on. Defaults to the start of the run. */ - now?: Date; -} - /** * Run the dashboard date-window family against a live stack, as the principal * `token` signs in: for every widget a `dateRange` binds, every count measure @@ -605,12 +550,16 @@ export interface DashboardWindowProofOptions { * known days, and each difference is compared with the rows that window * selects. Returns a structured report; a per-widget failure is collected, * not thrown. + * + * 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. */ export async function runDashboardWindowProofs( stack: VerifyStack, token: string, config: any, - options: DashboardWindowProofOptions = {}, ): Promise { const { cases, dashboards } = derive(config); const crud = new Map(deriveCrudCases(config).map((c) => [c.object, c])); @@ -623,13 +572,14 @@ export async function runDashboardWindowProofs( const utc = timezone === undefined || timezone === 'UTC'; const tokenCtx = (now: Date) => ({ now, timezone, userId: principal }); - const start = options.now ?? new Date(); + const start = new Date(); const presets: DashboardWindowPreset[] = DATE_RANGE_PRESETS.map((preset) => { const [from, to] = DATE_RANGE_PRESET_MACRO_WINDOWS[preset]; const condition: Record = { $gte: from, ...(to ? { $lte: to } : {}) }; return { preset, condition, resolved: resolveFilterTokens(condition, tokenCtx(start)) as Record }; }); const todayAt = (now: Date) => String(resolveFilterTokens('{today}', tokenCtx(now))); + const startDay = todayAt(start); const registered = (object: string): Record => { try { @@ -687,7 +637,7 @@ export async function runDashboardWindowProofs( } else { const { body } = fillRelationalRefs(c, ids); try { - const [written] = await stack.seed(object, [fixtureBody(body, objectDefs.get(object), ++counter)]); + const [written] = await stack.seed(object, [fixtureBody(body, objectDefs.get(object), ++counter, FIXTURE_TAG)]); made = written?.id != null ? { id: String(written.id) } : { reason: `the seed of ${object} returned no id` }; } catch (e) { made = { reason: `the seed of ${object} was refused: ${clip((e as Error)?.message, 160)}` }; @@ -843,6 +793,13 @@ export async function runDashboardWindowProofs( return; } const day0 = todayAt(new Date()); + // The presets resolved when the run started; the server resolves them on + // today. Once the day has turned since then, the ground truth would judge + // this measure against yesterday's edges, so it is not run. + if (day0 !== startDay) { + 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`); + return; + } const before = new Map(); for (const p of presets) before.set(p.preset, await ask(c.dataset!, selection(p.condition), m.measure)); @@ -869,7 +826,7 @@ export async function runDashboardWindowProofs( let full = false; for (const day of days) { const row = (wide: boolean): Record => ({ - ...fixtureBody(body, objectDefs.get(c.object!), ++counter, wide), + ...fixtureBody(body, objectDefs.get(c.object!), ++counter, FIXTURE_TAG, wide), ...(ownerColumn ? { owner_id: principal } : {}), ...Object.fromEntries(held), [c.field!]: stamp(day), diff --git a/packages/verify/src/delete-behavior.ts b/packages/verify/src/delete-behavior.ts index 5db1a1ef22f..b17b9f7f22d 100644 --- a/packages/verify/src/delete-behavior.ts +++ b/packages/verify/src/delete-behavior.ts @@ -65,7 +65,7 @@ import { FieldSchema, REFERENCE_VALUE_TYPES, isMultiValueField, referenceTargetOf } from '@objectstack/spec/data'; import { declaredCollection } from './artifact-collections.js'; -import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import { deriveCrudCases, fillRelationalRefs, laterBy, uniqueFields, type CrudCase } from './derive.js'; import type { VerifyStack } from './harness.js'; /** A behaviour a declaration can state (`FieldSchema.deleteBehavior`). */ @@ -259,32 +259,6 @@ function references(value: unknown, id: string): boolean { return value != null && String(value) === id; } -/** - * The fields of `objectDef` a uniqueness constraint covers: a field declaring a - * `unique` scope, and every field of a unique `indexes[]` entry. - */ -function uniqueFields(objectDef: any): Set { - const out = new Set(); - for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { - if (def?.unique !== undefined && def?.unique !== false) out.add(name); - } - for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { - if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); - } - return out; -} - -const DAY_MS = 86_400_000; - -/** A synthesized `date` / `datetime` value moved `days` later, in the same spelling. */ -function laterBy(value: unknown, days: number): unknown { - if (typeof value !== 'string' || days === 0) return value; - const at = Date.parse(value); - if (Number.isNaN(at)) return value; - const moved = new Date(at + days * DAY_MS).toISOString(); - return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; -} - /** * The smallest record of `objectDef` the CRUD derivation can offer: the values * `deriveCrudCases` synthesized for the fields the object REQUIRES (its @@ -295,6 +269,12 @@ function laterBy(value: unknown, days: number): unknown { * delete: each `date` / `datetime` value is one day later than the one declared * before it (a "starts before it ends" rule holds), and each unique-constrained * value is made distinct by `n`. + * + * This is not `derive.ts`'s `fixtureBody`, which the other families share, and + * no parameter could make it one without changing a value this family writes: + * here a unique string always takes a plain `-n` (an email before its `@`), a + * date, URL or phone number included, and every `date` / `datetime` field + * counts toward the next one's offset, whether its value is a string or not. */ function fixtureBody(body: Record, objectDef: any, n: number): Record { const unique = uniqueFields(objectDef); diff --git a/packages/verify/src/derive.test.ts b/packages/verify/src/derive.test.ts index 9cf64a187d6..5819ebc93e4 100644 --- a/packages/verify/src/derive.test.ts +++ b/packages/verify/src/derive.test.ts @@ -7,7 +7,7 @@ import { Field, FieldType, isMultiValueField } from '@objectstack/spec/data'; // and the type-layer ratchet reads this file (`TEST_DEBT`, via the package's // tsconfig with the test exclusion lifted). The bare specifier here was // costing 1 × TS2835 plus one TS7006 per callback parameter in the file. -import { deriveCrudCases } from './derive.js'; +import { deriveCrudCases, fixtureBody, laterBy, uniqueFields, vary } from './derive.js'; describe('deriveCrudCases — federated (external) objects (ADR-0015)', () => { it('blocks a read-only external object so verify never probe-inserts it', () => { @@ -307,3 +307,77 @@ describe('deriveCrudCases — a sample takes the shape the field is STORED in (# ); }); }); + +/** + * The fixture helpers four proof families share (delete-behavior, + * unique-scope, parent-write-reach, dashboard-window). Each family used to + * carry a private copy; one implementation now serves all four, so these pins + * hold the values a family writes, not only that the helpers run. + */ +describe('the fixture helpers the proof families share', () => { + it('laterBy moves a date or a datetime by days, in the spelling it came in, and leaves anything else alone', () => { + expect(laterBy('2026-10-13', 2)).toBe('2026-10-15'); + expect(laterBy('2026-02-28', 1)).toBe('2026-03-01'); + expect(laterBy('2026-10-13T12:00:00.000Z', 1)).toBe('2026-10-14T12:00:00.000Z'); + expect(laterBy('2026-10-13T23:59:59Z', 1)).toBe('2026-10-14T23:59:59.000Z'); + expect(laterBy('2026-10-13', 0)).toBe('2026-10-13'); + expect(laterBy('not a date', 3)).toBe('not a date'); + for (const v of [null, undefined, 42, true]) expect(laterBy(v, 3)).toBe(v); + }); + + it("vary marks a string with the family's own tag, so each family keeps the values it writes", () => { + expect(vary('Acme', 3, 'u')).toBe('Acme-u3'); + expect(vary('Acme', 3, 'p')).toBe('Acme-p3'); + expect(vary('Acme', 3, 'w')).toBe('Acme-w3'); + expect(vary('ada@example.com', 2, 'u')).toBe('ada-u2@example.com'); + expect(vary('ada@example.com', 2, 'w')).toBe('ada-w2@example.com'); + expect(vary('https://example.com/', 1, 'p')).toBe('https://example.com/p1'); + expect(vary('https://example.com', 1, 'w')).toBe('https://example.com/w1'); + // A number, a date and a phone number take `k` alone, whatever the tag. + expect(vary(10, 3, 'u')).toBe(13); + expect(vary('2026-10-13', 2, 'p')).toBe('2026-10-15'); + expect(vary('+15551234', 4, 'w')).toBe('+155512344'); + }); + + it('uniqueFields names every field a unique scope or a unique index covers, and nothing else', () => { + const def = { + fields: { + code: { type: 'text', unique: true }, + email: { type: 'email', unique: 'organization' }, + nick: { type: 'text', unique: false }, + plain: { type: 'text' }, + }, + indexes: [{ fields: ['region', 'number'], unique: true }, { fields: ['plain'] }], + }; + expect([...uniqueFields(def)]).toEqual(['code', 'email', 'region', 'number']); + expect([...uniqueFields({})]).toEqual([]); + expect([...uniqueFields(undefined)]).toEqual([]); + }); + + it('fixtureBody keeps the required fields, staggers the dates and varies the unique values with the tag', () => { + const def = { + fields: { + name: { type: 'text', required: true, unique: true }, + starts_on: { type: 'date', required: true }, + ends_at: { type: 'datetime', required: true }, + email: { type: 'email', required: true, unique: 'organization' }, + note: { type: 'text' }, + missing: { type: 'text', required: true }, + }, + }; + const body = { name: 'Acme', starts_on: '2026-10-13', ends_at: '2026-10-13T12:00:00.000Z', email: 'ada@example.com', note: 'n' }; + expect(fixtureBody(body, def, 4, 'u')).toEqual({ + name: 'Acme-u4', + starts_on: '2026-10-13', + ends_at: '2026-10-14T12:00:00.000Z', + email: 'ada-u4@example.com', + }); + expect(fixtureBody(body, def, 4, 'w', true)).toEqual({ + name: 'Acme-w4', + starts_on: '2026-10-13', + ends_at: '2026-10-14T12:00:00.000Z', + email: 'ada-w4@example.com', + note: 'n', + }); + }); +}); diff --git a/packages/verify/src/derive.ts b/packages/verify/src/derive.ts index ba277d29a30..655a802e381 100644 --- a/packages/verify/src/derive.ts +++ b/packages/verify/src/derive.ts @@ -384,3 +384,87 @@ export function fillRelationalRefs( } return { body }; } + +// ── Fixture helpers the proof families share ──────────────────────────────── +// +// The delete-behavior, unique-scope, parent-write-reach and dashboard-window +// families write fixture rows from the body a CRUD case synthesizes, and these +// are the pieces they have in common. They are exported from this module for +// those families only, ⛔ never from `index.ts`: they are not part of the +// package's public surface. + +const DAY_MS = 86_400_000; + +/** A `date` / `datetime` value moved `days` later, in the same spelling. Any other value comes back as it is. */ +export function laterBy(value: string, days: number): string; +export function laterBy(value: unknown, days: number): unknown; +export function laterBy(value: unknown, days: number): unknown { + if (typeof value !== 'string' || days === 0) return value; + const at = Date.parse(value); + if (Number.isNaN(at)) return value; + const moved = new Date(at + days * DAY_MS).toISOString(); + return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; +} + +/** + * The `k`-th distinct value of a sample `base`. A number moves by `k`, a date + * by `k` days, and a phone number gains `k`'s digits. Any other string takes + * `tag`, the calling family's own letter, beside `k`: `Acme-u3`, + * `ada-u3@example.com`, `https://example.com/u3`. Each family passes its own + * letter, so the values it writes are the ones it has always written. + */ +export function vary(base: string | number, k: number, tag: string): string | number { + if (typeof base === 'number') return base + k; + if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k); + const at = base.indexOf('@'); + if (at > 0) return `${base.slice(0, at)}-${tag}${k}${base.slice(at)}`; + if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/${tag}${k}`; + if (/^\+\d+$/.test(base)) return `${base}${k}`; + return `${base}-${tag}${k}`; +} + +/** + * The fields of `objectDef` a uniqueness constraint covers: a field declaring a + * `unique` scope, and every field of a unique `indexes[]` entry. + */ +export function uniqueFields(objectDef: any): Set { + const out = new Set(); + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if (def?.unique !== undefined && def?.unique !== false) out.add(name); + } + for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { + if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); + } + return out; +} + +/** + * A record of `objectDef` built from the `body` a CRUD case synthesized (its + * required relations already filled). By default it is the smallest: the + * values for the fields the object REQUIRES, so every optional field takes the + * app's own default. With `full` it carries every field the case synthesized, + * which is what an app rule that requires a field only in some state asks for. + * Each `date` / `datetime` value is one day after the one declared before it, + * so a "starts before it ends" rule holds. Each unique-constrained value is + * made distinct by `n` through {@link vary}, with the family's `tag`, so a + * fixture collides with nothing but the value a probe writes on purpose. + */ +export function fixtureBody( + body: Record, + objectDef: any, + n: number, + tag: string, + full = false, +): Record { + const unique = uniqueFields(objectDef); + const out: Record = {}; + let dates = 0; + for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { + if ((!full && def?.required !== true) || !(name in body)) continue; + let value = body[name]; + if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++); + if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n, tag); + out[name] = value; + } + return out; +} diff --git a/packages/verify/src/index.ts b/packages/verify/src/index.ts index 1f1a3732d6b..75db8542158 100644 --- a/packages/verify/src/index.ts +++ b/packages/verify/src/index.ts @@ -175,7 +175,6 @@ export type { DashboardWindowWidgetStatus, DashboardWindowWidgetResult, DashboardWindowReport, - DashboardWindowProofOptions, } from './dashboard-window.js'; // ADR-0060 — reusable conformance-ledger helper (static complement to the diff --git a/packages/verify/src/parent-write-reach.ts b/packages/verify/src/parent-write-reach.ts index 560c6d698f8..8fbf606b706 100644 --- a/packages/verify/src/parent-write-reach.ts +++ b/packages/verify/src/parent-write-reach.ts @@ -69,7 +69,7 @@ import { ObjectSchema, referenceCarrierOf } from '@objectstack/spec/data'; import { objectPermissionGrants, type PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin, securityDefaultPermissionSets, appSecurityPluginOptions } from '@objectstack/plugin-security'; import { declaredCollection } from './artifact-collections.js'; -import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import { deriveCrudCases, fillRelationalRefs, fixtureBody, type CrudCase } from './derive.js'; import type { VerifyStack } from './harness.js'; import { declaredPositionNames, provisionRlsPositionPersona } from './rls.js'; @@ -477,59 +477,12 @@ const plain = (a: ParentWriteReachAnswer): ParentWriteReachAnswer => ({ // ── Fixtures ──────────────────────────────────────────────────────────────── -const DAY_MS = 86_400_000; - -/** A `date` / `datetime` value moved `days` later, in the same spelling. */ -function laterBy(value: string, days: number): string { - const at = Date.parse(value); - if (Number.isNaN(at) || days === 0) return value; - const moved = new Date(at + days * DAY_MS).toISOString(); - return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; -} - -/** The `k`-th distinct value of a sample `base`. */ -function vary(base: string | number, k: number): string | number { - if (typeof base === 'number') return base + k; - if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k); - const at = base.indexOf('@'); - if (at > 0) return `${base.slice(0, at)}-p${k}${base.slice(at)}`; - if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/p${k}`; - if (/^\+\d+$/.test(base)) return `${base}${k}`; - return `${base}-p${k}`; -} - -/** The fields of `objectDef` a uniqueness constraint covers: a declared field scope, or a unique index entry. */ -function uniqueFields(objectDef: any): Set { - const out = new Set(); - for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { - if (def?.unique !== undefined && def?.unique !== false) out.add(name); - } - for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { - if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); - } - return out; -} - /** - * The smallest record of `objectDef` the CRUD derivation can offer (the - * unique-scope family's fixture shape): its values for the fields the object - * REQUIRES, so every optional field takes the app's own default. Each `date` / - * `datetime` is one day after the one declared before it, and each - * unique-constrained value is made distinct by `n`. + * This family's letter on every string value it varies. A fixture is the + * smallest record the CRUD derivation can offer (`derive.ts`'s `fixtureBody`, + * the unique-scope family's shape). */ -function fixtureBody(body: Record, objectDef: any, n: number): Record { - const unique = uniqueFields(objectDef); - const out: Record = {}; - let dates = 0; - for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { - if (def?.required !== true || !(name in body)) continue; - let value = body[name]; - if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++); - if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n); - out[name] = value; - } - return out; -} +const FIXTURE_TAG = 'p'; // ── The verdict ───────────────────────────────────────────────────────────── @@ -695,7 +648,7 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) const refs = (c.relationalRefs ?? []).filter((r) => !(r.field in overrides)); const { body, missing } = fillRelationalRefs({ ...c, relationalRefs: refs }, ids); if (missing) return { reason: missing }; - const row = { ...fixtureBody(body, objectDefs.get(object), ++counter), ...overrides, ...(hasOwnerColumn(object) ? { owner_id: owner } : {}) }; + const row = { ...fixtureBody(body, objectDefs.get(object), ++counter, FIXTURE_TAG), ...overrides, ...(hasOwnerColumn(object) ? { owner_id: owner } : {}) }; try { const [written] = await stack.seed(object, [row]); return written?.id != null ? { id: String(written.id) } : { reason: `the seed of ${object} returned no id` }; @@ -851,7 +804,7 @@ export async function runParentWriteReachProofs(stack: VerifyStack, config: any) const refs = (cc.relationalRefs ?? []).filter((r) => r.field !== field); const { body } = fillRelationalRefs({ ...cc, relationalRefs: refs }, ids); const answer = await write(stack, p.token, c.object, 'insert', { - ...fixtureBody(body, objectDefs.get(c.object), ++counter), + ...fixtureBody(body, objectDefs.get(c.object), ++counter, FIXTURE_TAG), [field]: masterId, }); const made = answer.kind === 'written' && answer.id ? await row(c.object, answer.id) : undefined; diff --git a/packages/verify/src/unique-scope.ts b/packages/verify/src/unique-scope.ts index c81e31ce5f2..109a3567d92 100644 --- a/packages/verify/src/unique-scope.ts +++ b/packages/verify/src/unique-scope.ts @@ -82,7 +82,7 @@ import { } from '@objectstack/spec/data'; import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { declaredCollection } from './artifact-collections.js'; -import { deriveCrudCases, fillRelationalRefs, type CrudCase } from './derive.js'; +import { deriveCrudCases, fillRelationalRefs, fixtureBody, vary, type CrudCase } from './derive.js'; import type { VerifyStack } from './harness.js'; /** A scope a declaration can state (`UniqueScopeSchema`, minus `false`). */ @@ -334,26 +334,8 @@ export interface UniqueScopeProofOptions { const clip = (s: unknown, n = 200): string => String(s ?? '').slice(0, n); -const DAY_MS = 86_400_000; - -/** A `date` / `datetime` value moved `days` later, in the same spelling. */ -function laterBy(value: string, days: number): string { - const at = Date.parse(value); - if (Number.isNaN(at) || days === 0) return value; - const moved = new Date(at + days * DAY_MS).toISOString(); - return /^\d{4}-\d{2}-\d{2}$/.test(value) ? moved.slice(0, 10) : moved; -} - -/** The `k`-th fresh value of a field whose sample is `base`. */ -function vary(base: string | number, k: number): string | number { - if (typeof base === 'number') return base + k; - if (/^\d{4}-\d{2}-\d{2}/.test(base)) return laterBy(base, k); - const at = base.indexOf('@'); - if (at > 0) return `${base.slice(0, at)}-u${k}${base.slice(at)}`; - if (/^https?:\/\//.test(base)) return `${base.replace(/\/$/, '')}/u${k}`; - if (/^\+\d+$/.test(base)) return `${base}${k}`; - return `${base}-u${k}`; -} +/** This family's letter on every string value it varies (`derive.ts`'s `vary`). */ +const FIXTURE_TAG = 'u'; /** Does a stored value equal the value written (a date read back in another spelling counts)? */ function holds(stored: unknown, value: unknown): boolean { @@ -366,18 +348,6 @@ function holds(stored: unknown, value: unknown): boolean { return String(stored) === String(value); } -/** The fields of `objectDef` a uniqueness constraint covers: a declared field scope, or a unique index entry. */ -function uniqueFields(objectDef: any): Set { - const out = new Set(); - for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { - if (def?.unique !== undefined && def?.unique !== false) out.add(name); - } - for (const index of Array.isArray(objectDef?.indexes) ? objectDef.indexes : []) { - if (index?.unique) for (const f of Array.isArray(index.fields) ? index.fields : []) out.add(String(f)); - } - return out; -} - /** The columns of each unique `indexes[]` entry of `objectDef` that includes `field`. */ function uniqueIndexesOver(objectDef: any, field: string): string[][] { const out: string[][] = []; @@ -388,28 +358,6 @@ function uniqueIndexesOver(objectDef: any, field: string): string[][] { return out; } -/** - * The smallest record of `objectDef` the CRUD derivation can offer: its values - * for the fields the object REQUIRES (required relations already filled), so - * every optional field takes the app's own default. Each `date` / `datetime` - * is one day after the one declared before it, and each unique-constrained - * value is made distinct by `n`, so a fixture collides with nothing but the - * value a probe writes on purpose. - */ -function fixtureBody(body: Record, objectDef: any, n: number): Record { - const unique = uniqueFields(objectDef); - const out: Record = {}; - let dates = 0; - for (const [name, def] of Object.entries(objectDef?.fields ?? {})) { - if (def?.required !== true || !(name in body)) continue; - let value = body[name]; - if ((def?.type === 'date' || def?.type === 'datetime') && typeof value === 'string') value = laterBy(value, dates++); - if (unique.has(name) && (typeof value === 'string' || typeof value === 'number')) value = vary(value, n); - out[name] = value; - } - return out; -} - /** A writer: one door, as one principal, into one organization. */ interface Writer { label: string; @@ -679,7 +627,7 @@ export async function runUniqueScopeProofs( createdIds.set(w.label, ids); const { body, missing } = fillRelationalRefs(c, ids); if (missing) return { reason: missing }; - const answer = await w.insert(object, { ...fixtureBody(body, objectDefs.get(object), n), ...overrides }); + const answer = await w.insert(object, { ...fixtureBody(body, objectDefs.get(object), n, FIXTURE_TAG), ...overrides }); if (answer.kind !== 'written') return { answer, reason: `${w.label}: create ${object} ${answer.kind} (${answer.code ?? answer.status ?? ''}): ${answer.detail ?? ''}` }; return answer.id ? { id: answer.id, answer } : { answer, reason: `${w.label}: create ${object} returned no id` }; } @@ -732,7 +680,7 @@ export async function runUniqueScopeProofs( const coveringIndexes = uniqueIndexesOver(objectDefs.get(c.object), c.field); // Probe values draw on the fixtures' counter, so no probe value equals a fixture's. const fresh = async (w: Writer): Promise<{ value?: unknown; reason?: string }> => { - if (source.base !== undefined) return { value: vary(source.base, ++variant) }; + if (source.base !== undefined) return { value: vary(source.base, ++variant, FIXTURE_TAG) }; const t = await create(w, source.target!, {}); return t.id ? { value: t.id } : { reason: `a record to reference: ${t.reason}` }; };