From 90ec292b899b3bcb616b35bef3788c36a50934f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 07:11:50 +0000 Subject: [PATCH] fix(types,core): adopt the spec's date-default refinement; `{ preset }` becomes an ADR-0089 legacy alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes the maintainer ruling on objectui#4165: the spec stays strict, the bare preset NAME is the single canonical spelling of a `type: 'date'` global filter's `defaultValue`, and the stored `{ preset }` object form becomes a documented legacy alias — lifted on read, rewritten on next save, with the retirement window recorded at the read site. - `@object-ui/types`: the `defaultValue` divergence is retired and the spec's rc.6 refinement is carried. A refined object schema in zod 4 closes `.extend()`, `.omit()` and (by typing overrides `never`) `.safeExtend()`, so the schema spreads the spec's `.shape` and re-attaches the spec's object-level rules by delegating to the spec schema itself — no local restatement of the grammar. `options`/`optionsFrom` divergences unaffected. - New zod-free module `dashboard-filter-alias.ts` exports the lift, carrying the ADR-0089 window. - `@object-ui/core`: `resolveDashboardFilterDefs` lifts each stored entry and warns, so a surviving legacy document is visible rather than silently tolerated. - `@object-ui/plugin-designer`: `DashboardDesignPage` lifts into the editable draft and at the save funnel, so the next save persists the canonical name. - #4169's standstill tripwire went red exactly as designed and is deleted with its note; the `check:spec-symbols` ALLOW entry for `GlobalFilterSchema` is burned down, because the delegation made the derivation visible to that guard again. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/global-filter-preset-alias-4165.md | 15 + .../utils/__tests__/dashboard-filters.test.ts | 96 +++++++ packages/core/src/utils/dashboard-filters.ts | 93 ++++++- .../DashboardFilterBar.dateDefault.test.tsx | 35 +++ .../DashboardDesignPage.presetAlias.test.tsx | 138 ++++++++++ .../src/pages/DashboardDesignPage.tsx | 23 +- .../report-chart-query-spec-parity.test.ts | 260 +++++++++++++----- packages/types/src/dashboard-filter-alias.ts | 135 +++++++++ packages/types/src/index.ts | 8 + packages/types/src/zod/complex.zod.ts | 80 +++--- scripts/check-spec-symbol-derivation.mjs | 22 -- 11 files changed, 774 insertions(+), 131 deletions(-) create mode 100644 .changeset/global-filter-preset-alias-4165.md create mode 100644 packages/plugin-designer/src/__tests__/DashboardDesignPage.presetAlias.test.tsx create mode 100644 packages/types/src/dashboard-filter-alias.ts diff --git a/.changeset/global-filter-preset-alias-4165.md b/.changeset/global-filter-preset-alias-4165.md new file mode 100644 index 0000000000..315cafb2f7 --- /dev/null +++ b/.changeset/global-filter-preset-alias-4165.md @@ -0,0 +1,15 @@ +--- +'@object-ui/types': patch +'@object-ui/core': patch +'@object-ui/plugin-designer': patch +--- + +A dashboard date filter's default has one spelling again — the bare preset name — and the `{ preset }` object becomes a documented legacy alias with a retirement window + +`@objectstack/spec` 17.0.0-rc.6 added a cross-field refinement to `GlobalFilterSchema` holding a `type: 'date'` filter's `defaultValue` to three spellings: a preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro token (`{today}`). objectui's derived schema had widened `defaultValue` to `z.any()` and did not carry the refinement, so it accepted `{ preset: 'last_7_days' }` — metadata the platform refuses. That is the tolerant-consumer shape where the designer goes green and the save fails server-side, and it is now closed: the refinement is adopted, the widening is retired, and the object form is refused with the spec's own message. + +Per the maintainer ruling on objectui#4165, the spec stays strict and the bare preset name is the single canonical spelling. `{ preset }` is handled as an ADR-0089 legacy alias rather than by a permanently tolerant schema: `liftLegacyGlobalFilterDefault` / `liftLegacyDashboardFilterDefaults` (new exports on `@object-ui/types`) convert it to the bare name, `@object-ui/core`'s `resolveDashboardFilterDefs` applies the lift when it reads a stored dashboard, and the console's dashboard designer applies it as the document enters the editable draft so the next save persists the canonical spelling. The retirement window is recorded at the read site: the alias may be removed in `@object-ui/types` 18.0.0, and every lift warns on the console so a surviving legacy document is visible rather than silently tolerated. + +No stored dashboard has to change for this release. The lift means a document carrying the object form keeps loading and rendering exactly as before — measured, not assumed: a legacy declaration already resolved correctly, because `{ preset }` also happens to be the runtime value shape objectui's own date filters use, and that coincidence is why the object form went unnoticed for so long. What changes is that the declaration is now canonicalized on read and rewritten on save, so the two spellings converge instead of accreting. + +The other two divergences in this schema — the bare-string `options` shorthand and the optional `optionsFrom.labelField` — are unaffected. Carrying the spec's refinement while keeping them needed a new composition: a refined object schema in zod 4 rejects `.extend()` and `.omit()` outright and types every `.safeExtend()` override as `never`, so objectui's schema now spreads the spec's shape and re-attaches the spec's object-level rules by delegating to the spec schema itself. Nothing restates the spec's grammar, and a refinement the spec adds later flows in with no change here. diff --git a/packages/core/src/utils/__tests__/dashboard-filters.test.ts b/packages/core/src/utils/__tests__/dashboard-filters.test.ts index fcff965d1d..4a011e5698 100644 --- a/packages/core/src/utils/__tests__/dashboard-filters.test.ts +++ b/packages/core/src/utils/__tests__/dashboard-filters.test.ts @@ -482,3 +482,99 @@ describe('DATE_RANGE_PRESETS is the spec\'s list, not a copy of it', () => { } }); }); + +// --------------------------------------------------------------------------- +// objectui#4165 — the ADR-0089 legacy-alias window for a date filter's +// declared `defaultValue`. +// +// Maintainer ruling (2026-08-11): the spec stays strict, the bare preset NAME +// is the single canonical spelling, and the stored `{ preset }` object form is +// a documented legacy alias — lifted on read, rewritten on next save. +// +// READ THE STRENGTHS OF THESE PINS HONESTLY — reverse verification (deleting +// the lift call in `resolveDashboardFilterDefs`, re-running, restoring) turned +// exactly ONE of them red: the warning. The convergence and rendering pins were +// green with the lift and green without it, and it is worth knowing why rather +// than mistaking them for proof. +// +// A legacy DECLARATION (`defaultValue: { preset }`) happens to be shaped like +// the runtime VALUE `normalizeDateDefault` produces for the canonical spelling, +// and `normalizeDateDefault` passes non-strings straight through. So a stored +// `{ preset }` dashboard already rendered correctly before this change and +// still would with the lift removed. That coincidence is the whole reason the +// object form looked harmless for so long — and it is how the divergence prose +// drifted into calling it "the on-disk form". +// +// So what each pin is for: +// - the WARNING pin is the one that can detect the lift's absence, and it is +// what makes the window closable at all (ADR-0078); +// - the CONVERGENCE and BOUNDS pins are regression guards, not evidence: they +// say the lift did not break the shape everything downstream reads. Kept +// deliberately, labelled deliberately; +// - the behavioural teeth of #4165 are elsewhere and DO go red — the schema's +// refusal (`@object-ui/types` parity suite) and the rewrite-on-save +// (`@object-ui/plugin-designer`'s DashboardDesignPage pin). +// --------------------------------------------------------------------------- +describe('[#4165] legacy `{ preset }` declaration — ADR-0089 alias lift', () => { + const legacy = { field: 'created_at', type: 'date', label: 'Date Range', defaultValue: { preset: 'last_7_days' } }; + const canonical = { field: 'created_at', type: 'date', label: 'Date Range', defaultValue: 'last_7_days' }; + + const resolveQuietly = (globalFilters: unknown[]) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + return { + defs: resolveDashboardFilterDefs({ globalFilters } as any), + warnings: warn.mock.calls.map((c) => String(c[0])), + }; + } finally { + warn.mockRestore(); + } + }; + + // Regression guard (see the block note): green with or without the lift. + it('resolves to defs identical to the canonical spelling', () => { + const { defs: fromLegacy } = resolveQuietly([legacy]); + const { defs: fromCanonical } = resolveQuietly([canonical]); + expect(fromLegacy).toEqual(fromCanonical); + // …and specifically to the runtime VALUE shape the date consumers read. + // Note this is `{ preset }` again — the round trip is not a no-op, it is + // declaration → canonical name → value. See `normalizeDateDefault`'s note + // on declaration space vs value space; conflating the two is what #4165 + // was filed about. + expect(fromLegacy[0].defaultValue).toEqual({ preset: 'last_7_days' }); + }); + + it('produces the same query bounds as the canonical spelling', () => { + const { defs } = resolveQuietly([legacy]); + expect(buildFilterCondition(defs[0], defs[0].defaultValue)).toEqual({ + $gte: '{7_days_ago}', + $lte: '{today}', + }); + }); + + it('warns when it lifts, so a surviving legacy document is visible', () => { + // ADR-0078 — a silent lift can never be retired: nothing would ever show + // that the last legacy document is gone. + const { warnings } = resolveQuietly([legacy]); + const lift = warnings.filter((m) => m.includes('LEGACY')); + expect(lift).toHaveLength(1); + expect(lift[0]).toContain('created_at'); + expect(lift[0]).toContain('last_7_days'); + expect(lift[0]).toContain('#4165'); + }); + + it('says nothing at all for a canonical declaration', () => { + const { warnings } = resolveQuietly([canonical]); + expect(warnings).toEqual([]); + }); + + it('does not lift a `{ preset, from, to }` value — it has no canonical spelling', () => { + // Left exactly as declared; `buildFilterCondition` still reads the bounds + // off it, so nothing breaks, but no data is silently dropped to fit the + // bare-name form. + const withBounds = { field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days', from: '2026-01-01' } }; + const { defs, warnings } = resolveQuietly([withBounds]); + expect(defs[0].defaultValue).toEqual({ preset: 'last_7_days', from: '2026-01-01' }); + expect(warnings.filter((m) => m.includes('LEGACY'))).toEqual([]); + }); +}); diff --git a/packages/core/src/utils/dashboard-filters.ts b/packages/core/src/utils/dashboard-filters.ts index 77b5658c95..3dfe4bbf24 100644 --- a/packages/core/src/utils/dashboard-filters.ts +++ b/packages/core/src/utils/dashboard-filters.ts @@ -21,6 +21,7 @@ */ import type { DashboardComponentSchema, DashboardWidgetSchema, PageVariable } from '@object-ui/types'; +import { liftLegacyGlobalFilterDefault } from '@object-ui/types'; import { DATE_RANGE_PRESETS, type DateRangePreset } from '@objectstack/spec/ui'; import { resolveDateMacros } from './date-macros.js'; @@ -169,10 +170,93 @@ function warnDateFilter(message: string): void { if (typeof console !== 'undefined') console.warn(`[dashboard-filters] ${message}`); } +/** + * Apply the ADR-0089 legacy-alias lift to ONE stored `globalFilters` entry, and + * say so out loud when it fires (objectui#4165). + * + * ## The alias, at this read site + * + * **What it is.** `defaultValue: { preset: 'last_7_days' }` on a `type: 'date'` + * filter. The canonical spelling is the bare preset NAME, + * `defaultValue: 'last_7_days'` — one of the three the spec's rc.6 refinement + * accepts (preset name / ISO date / date-macro token). + * + * **Why it is lifted here rather than tolerated.** Maintainer ruling on + * objectui#4165 (2026-08-11): 「spec stays strict — no widening」. objectui's + * `GlobalFilterSchema` now carries that refinement, so a document holding the + * object form fails validation; lifting it BEFORE anything reads the entry + * makes the declaration canonical by construction. The lift itself lives in + * `@object-ui/types`' `dashboard-filter-alias.ts` (one implementation, shared + * with the designer's rewrite-on-save path); this is one of its two call sites. + * + * **What it does NOT buy, measured.** It does not rescue rendering. A legacy + * declaration already resolved correctly before #4165 and still does with this + * call deleted — reverse-verified, only the warning below changes. The reason + * is a coincidence worth knowing: `{ preset }` is also the runtime VALUE shape + * `normalizeDateDefault` produces for the canonical name, and that function + * passes non-strings through untouched. That coincidence is why the object form + * looked harmless for so long, and how the schema's own prose drifted into + * calling it "the on-disk form". Read this call as canonicalization plus + * observability, not as a repair — claiming more would be claiming coverage the + * tests in `__tests__/dashboard-filters.test.ts` do not have. + * + * **When it may be removed.** At the next MAJOR of `@object-ui/types` (18.0.0). + * By then every dashboard opened in the designer has been rewritten to the bare + * name, because `DashboardDesignPage` lifts into the editable draft and the + * next save persists it. Delete this function and its call below together with + * the lift itself; a document still carrying the object form then gets the + * spec's named rejection, which is the intended end state. + * + * The warning is not decoration: a silent lift can never be retired, because + * nothing would ever show that the last legacy document is gone (ADR-0078 — + * nothing silently inert). + */ +function liftLegacyFilterDeclaration(filter: T): T { + const lifted = liftLegacyGlobalFilterDefault(filter); + if (lifted === filter) return filter; + const name = (filter as { name?: string; field?: string })?.name + ?? (filter as { field?: string })?.field + ?? '?'; + const preset = (lifted as { defaultValue?: unknown })?.defaultValue; + warnDateFilter( + `filter "${name}": \`defaultValue: { preset: ${JSON.stringify(preset)} }\` is a LEGACY ` + + `spelling (objectui#4165) and was lifted to the canonical bare preset name ` + + `${JSON.stringify(preset)}. Rewrite the stored dashboard — the object form is ` + + `refused by @objectstack/spec and its acceptance here ends with @object-ui/types 18.`, + ); + return lifted; +} + /** * Normalize a date filter's DECLARED default into the `DateRangeValue` shape * every date consumer in this module reads (framework#4475). * + * ## Declaration space vs value space — the distinction objectui#4165 turned on + * + * These are two different things that share the name `defaultValue`, and + * conflating them is what produced #4165: + * + * - the **declaration** is `globalFilters[].defaultValue` in a stored + * dashboard. `@objectstack/spec` owns it, it is `string | number | boolean`, + * and since rc.6 a refinement holds a `type: 'date'` one to a preset NAME, + * an ISO date or a date-macro token. A bare preset name is the canonical + * spelling and the object form is a retiring alias (see + * `liftLegacyFilterDeclaration` above); + * - the **value** is what this function RETURNS: `DashboardFilterDef + * .defaultValue`, which seeds the filter variable and is read by + * `DateRangeFilter` (`.preset`/`.from`/`.to`) and `buildFilterCondition`. + * That shape is `DateRangeValue`, it is objectui-internal, the spec has no + * opinion about it, and for a preset it is `{ preset }`. + * + * So this function converts declaration → value. It is NOT a producer of stored + * metadata: nothing writes a resolved `DashboardFilterDef` back into a + * dashboard document (measured in #4165 — `resolveDashboardFilterDefs`' only + * callers are `DashboardRenderer` and `DashboardWidgetInspector`, both read + * side). Making it emit the bare name instead would therefore not change one + * byte on disk; it would only hand `DateRangeFilter` a string it cannot read + * and `buildFilterCondition` a value it warns-and-skips — i.e. re-open + * framework#4475 exactly, which is why it keeps emitting `{ preset }`. + * * The built-in `dateRange` declaration has always been normalized this way — * `schema.dateRange.defaultRange` is a preset NAME and * `resolveDashboardFilterDefs` lifts it to `{ preset }`. A `globalFilters` @@ -254,8 +338,13 @@ export function resolveDashboardFilterDefs( }); } - for (const f of schema.globalFilters ?? []) { - if (!f?.field) continue; + for (const raw of schema.globalFilters ?? []) { + if (!raw?.field) continue; + // ADR-0089 legacy-alias lift (#4165) — a stored `defaultValue: { preset }` + // becomes the canonical bare name BEFORE anything else reads the entry, so + // a legacy dashboard resolves to byte-identical defs. See + // `liftLegacyFilterDeclaration` for the retirement window. + const f = liftLegacyFilterDeclaration(raw); const name = f.name || f.field; if (byName.has(name) && typeof console !== 'undefined') { console.warn(`[dashboard-filters] duplicate filter name "${name}" — the later definition wins`); diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx index b255eab049..3274758247 100644 --- a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx +++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateDefault.test.tsx @@ -62,3 +62,38 @@ describe('DashboardFilterBar — date filter default (framework#4475)', () => { expect(screen.getByTestId('dashboard-filter-created_at').textContent).toMatch(/All time/i); }); }); + +/** + * objectui#4165 — a stored dashboard carrying the LEGACY `{ preset }` spelling + * of the same declaration must render identically. + * + * The maintainer ruling made the bare preset name the single canonical + * spelling and turned the object form into an ADR-0089 legacy alias, lifted on + * read by `resolveDashboardFilterDefs`. "Lifted" is only worth anything if the + * user cannot tell: this is the display half of that claim, and it is asserted + * against the framework#4475 fixture above rather than a fresh one, so the two + * spellings are compared on identical input. + */ +describe('DashboardFilterBar — legacy `{ preset }` default (objectui#4165)', () => { + const LEGACY_FILTERS = [ + { field: 'created_at', type: 'date', label: 'Date Range', scope: 'dashboard', defaultValue: { preset: 'last_7_days' } }, + ] as any; + + it('renders a lifted legacy default exactly like the canonical one', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const legacyDefs = resolveDashboardFilterDefs({ globalFilters: LEGACY_FILTERS }); + const canonicalDefs = resolveDashboardFilterDefs({ globalFilters: SYSTEM_OVERVIEW_FILTERS }); + expect(legacyDefs).toEqual(canonicalDefs); + + const values = Object.fromEntries(legacyDefs.map((d) => [d.name, d.defaultValue])); + render(); + + const control = screen.getByTestId('dashboard-filter-created_at'); + expect(control.textContent).not.toMatch(/All time/i); + expect(control.textContent).toMatch(/last 7 days/i); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/plugin-designer/src/__tests__/DashboardDesignPage.presetAlias.test.tsx b/packages/plugin-designer/src/__tests__/DashboardDesignPage.presetAlias.test.tsx new file mode 100644 index 0000000000..dd6a9b9fd2 --- /dev/null +++ b/packages/plugin-designer/src/__tests__/DashboardDesignPage.presetAlias.test.tsx @@ -0,0 +1,138 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#4165 — the REWRITE-ON-SAVE half of the ADR-0089 alias window. + * + * The maintainer ruling (2026-08-11) made the bare preset NAME the single + * canonical spelling of a `type: 'date'` global filter's `defaultValue`, and + * turned the stored `{ preset }` object form into a documented legacy alias: + * *lift on read, rewrite on next save, retirement window recorded at the read + * site*. + * + * The lift half is pinned in `@object-ui/core` and `@object-ui/types`. This + * file pins the rewrite half, and it is the load-bearing one for the window's + * ability to ever CLOSE: the read-side lift makes a legacy document work + * forever, which by itself would keep the alias alive forever too. Something + * has to drain the legacy spelling off disk, and this page — the console's + * `/design/dashboard/:name` route — is the only place in objectui that both + * reads a stored dashboard document and writes one back (measured in #4165: + * `resolveDashboardFilterDefs`' own callers are read-side, and no code path + * persists a resolved filter def). + * + * Asserted against the real `dataSource.update` payload rather than component + * state, because the payload is the thing that reaches storage — a lift that + * only normalized the draft would look identical in state and change nothing + * on disk. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import type { DashboardComponentSchema } from '@object-ui/types'; + +const update = vi.fn().mockResolvedValue(undefined); +const dashboards: any[] = []; + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ dashboardName: 'sales' }), + useNavigate: () => vi.fn(), +})); + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + useAdapter: () => ({ update }), + useMetadata: () => ({ dashboards, refresh: () => Promise.resolve() }), + }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { DashboardDesignPage } from '../pages/DashboardDesignPage'; + +/** The stored document, in whichever spelling the case is about. */ +function storedDashboard(defaultValue: unknown): DashboardComponentSchema { + return { + type: 'dashboard', + name: 'sales', + title: 'Sales', + columns: 2, + widgets: [{ id: 'w1', type: 'metric', title: 'Revenue' }], + globalFilters: [ + { name: 'created_at', field: 'created_at', type: 'date', label: 'Date Range', defaultValue }, + ], + } as unknown as DashboardComponentSchema; +} + +/** + * Load the stored document into the page and make one ordinary edit. + * + * The edit is a real user gesture on a real control — adding a widget — rather + * than a synthetic Ctrl+S, because dispatching a `KeyboardEvent` to trigger a + * global listener is banned by ADR-0054 C1 (`object-ui/no-synthetic-event- + * trigger`). It is also the better test: "the next save" in the ruling means + * the next time the user changes anything, and `addWidget` touches only + * `widgets`, so whatever reaches `globalFilters` in the payload got there from + * the loaded draft and nowhere else. + */ +function loadAndEdit(stored: DashboardComponentSchema) { + dashboards.length = 0; + dashboards.push(stored); + render(); + fireEvent.click(screen.getByTestId('dashboard-add-metric')); + const calls = update.mock.calls; + return calls[calls.length - 1]; +} + +const savedFilters = (call: unknown[] | undefined) => + (call?.[2] as { globalFilters?: Array<{ defaultValue?: unknown }> })?.globalFilters; + +const savedFilterDefault = (call: unknown[] | undefined) => savedFilters(call)?.[0]?.defaultValue; + +describe('DashboardDesignPage — rewrite-on-save for the legacy `{ preset }` alias (#4165)', () => { + beforeEach(() => { + update.mockClear(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('persists the CANONICAL bare preset name for a dashboard stored with `{ preset }`', () => { + const call = loadAndEdit(storedDashboard({ preset: 'last_7_days' })); + expect(call?.[0]).toBe('sys_dashboard'); + expect(savedFilterDefault(call)).toBe('last_7_days'); + }); + + it('never writes the object form back — not even nested somewhere else', () => { + const call = loadAndEdit(storedDashboard({ preset: 'last_7_days' })); + expect(JSON.stringify(call?.[2])).not.toContain('"preset"'); + }); + + it('leaves an already-canonical dashboard\'s filters untouched, by identity', () => { + // The lift returns the SAME reference when there is nothing to lift, so + // opening a canonical dashboard cannot manufacture a spurious rewrite. The + // edit replaces `widgets`, so `globalFilters` should still be the very array + // that was loaded — identity, not just deep equality. + const stored = storedDashboard('last_7_days'); + const call = loadAndEdit(stored); + expect(savedFilters(call)).toBe((stored as unknown as { globalFilters: unknown }).globalFilters); + expect(savedFilterDefault(call)).toBe('last_7_days'); + }); + + it('leaves a `{ preset, from, to }` default alone — it has no canonical spelling', () => { + // Rewriting it would have to drop the bounds. Storage keeps what it had; + // the schema's named rejection is what surfaces the problem. + const stored = storedDashboard({ preset: 'last_7_days', from: '2026-01-01' }); + const call = loadAndEdit(stored); + expect(savedFilterDefault(call)).toEqual({ preset: 'last_7_days', from: '2026-01-01' }); + }); +}); diff --git a/packages/plugin-designer/src/pages/DashboardDesignPage.tsx b/packages/plugin-designer/src/pages/DashboardDesignPage.tsx index f8a5d4f448..3a05044716 100644 --- a/packages/plugin-designer/src/pages/DashboardDesignPage.tsx +++ b/packages/plugin-designer/src/pages/DashboardDesignPage.tsx @@ -10,6 +10,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { DashboardEditor } from '../DashboardEditor'; import type { DashboardComponentSchema } from '@object-ui/types'; +import { liftLegacyDashboardFilterDefaults } from '@object-ui/types'; import { toast } from 'sonner'; import { useAdapter } from '@object-ui/react'; import { useMetadata } from '@object-ui/react'; @@ -25,7 +26,17 @@ export function DashboardDesignPage() { const [schema, setSchema] = useState( () => - (dashboard as DashboardComponentSchema) || { + // REWRITE-ON-SAVE half of the ADR-0089 alias window (objectui#4165): the + // stored document is lifted off the legacy `defaultValue: { preset }` + // spelling as it enters the editable draft, so the next `handleChange` + // — every widget move, every import, every Ctrl+S — persists the + // canonical bare preset name through `saveSchema` below. Without this the + // window could never close: nothing else in objectui writes a dashboard + // document back, so a legacy document would stay legacy forever. + // + // The lift returns the SAME reference when there is nothing to lift, so a + // canonical dashboard is untouched and this cannot manufacture an edit. + liftLegacyDashboardFilterDefaults(dashboard as DashboardComponentSchema) || { type: 'dashboard', name: dashboardName ?? '', title: dashboardName ?? '', @@ -55,8 +66,14 @@ export function DashboardDesignPage() { const handleChange = useCallback( async (updated: DashboardComponentSchema) => { - setSchema(updated); - await saveSchema(updated); + // Lift here too, not only on load: this is the single funnel every write + // passes through, and `handleImport` feeds it a document from OUTSIDE the + // load path (a user-picked JSON file), which may carry the legacy + // spelling. Lifting at the funnel is what makes "nothing legacy is ever + // persisted" true rather than merely likely (#4165). + const canonical = liftLegacyDashboardFilterDefaults(updated); + setSchema(canonical); + await saveSchema(canonical); }, [saveSchema], ); diff --git a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts index f25a2f2584..be245fc678 100644 --- a/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts +++ b/packages/types/src/__tests__/report-chart-query-spec-parity.test.ts @@ -29,9 +29,6 @@ */ import { describe, it, expect } from 'vitest'; -// Needed to isolate the rc.6 `GlobalFilterSchema` refinement from the field type -// it hides behind — see the objectui#4165 pin below. -import { z } from 'zod'; import { createRequire } from 'node:module'; import { readFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; @@ -44,6 +41,10 @@ import { import type { JoinedReportBlock as SpecJoinedReportBlock } from '@objectstack/spec/ui'; import { AppContextSelectorSchema } from '../zod/app.zod.js'; import { DashboardWidgetSchema, GlobalFilterSchema } from '../zod/complex.zod.js'; +import { + liftLegacyGlobalFilterDefault, + liftLegacyDashboardFilterDefaults, +} from '../dashboard-filter-alias.js'; const shapeOf = (s: unknown) => (s as { shape: Record }).shape; @@ -141,96 +142,213 @@ describe('GlobalFilterSchema pinned divergences', () => { expect(SpecGlobalFilterSchema.safeParse({ field: 'region', options: [{ value: 'emea' }] }).success).toBe(false); }); - it('accepts the normalized date-preset default object', () => { - // framework#4475: `normalizeDateDefault` lifts a preset NAME into `{ preset }`, - // and stored dashboards carry that object form. + it('keeps `optionsFrom.labelField` optional', () => { expect(GlobalFilterSchema.safeParse({ - field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' }, + field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, }).success).toBe(true); expect(SpecGlobalFilterSchema.safeParse({ - field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' }, + field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, }).success).toBe(false); }); +}); + +/** + * The rc.6 date-`defaultValue` refinement, ADOPTED — objectui#4165. + * + * This block replaces the standstill tripwire that used to live here. That + * tripwire pinned "objectui does NOT carry the spec's refinement" and was + * designed to go red from either side the moment the conflict was resolved; + * the maintainer ruling of 2026-08-11 resolved it consumer-side, the tripwire + * went red exactly as designed, and it is deleted with its note rather than + * repaired. What follows pins the RESOLUTION, in both directions. + * + * The ruling, verbatim (objectui#4165): + * + * > 「spec stays strict — no widening。rc.6 的 refinement 成立,bare preset NAME + * > 是唯一 canonical spelling。」stored `{ preset }` becomes a documented legacy + * > alias per the ADR-0089 alias-retirement pattern — lift on read, rewrite on + * > next save, retirement window recorded at the read site. + * + * So there are two facts to hold apart, and a test that pins only one of them + * would read as coverage while missing half the contract: + * + * - the SCHEMA refuses the object form (the contract now matches the platform, + * so the designer can no longer go green on metadata the server rejects); + * - the READ PATH lifts it first, so a stored document carrying the alias is + * still loadable for the length of the window. + */ +describe('GlobalFilterSchema carries the spec rc.6 date refinement (objectui#4165)', () => { + const legacy = { field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' } }; + const canonical = { field: 'created_at', type: 'date', defaultValue: 'last_7_days' }; + + it('accepts the canonical bare preset NAME, on both sides', () => { + expect(GlobalFilterSchema.safeParse(canonical).success).toBe(true); + expect(SpecGlobalFilterSchema.safeParse(canonical).success).toBe(true); + }); + + it('accepts the refinement\'s two other spellings — ISO date and date macro', () => { + // Named here because the rejection message enumerates exactly three, and a + // consumer that adopted the rule but only ever tested presets would not + // notice if a future composition dropped the other two. + for (const defaultValue of ['2026-01-15', '2026-01-15T08:30:00Z', '{today}', '{30_days_ago}']) { + expect( + GlobalFilterSchema.safeParse({ field: 'created_at', type: 'date', defaultValue }).success, + `expected \`${defaultValue}\` to be accepted`, + ).toBe(true); + } + }); /** - * OPEN CONFLICT, pinned rather than resolved — objectui#4165. + * MIND THE LAYER — the issue's "the obvious probe is misleading" warning + * applies to this schema now too, in mirror image. * - * The divergence above used to be about a FIELD TYPE, and only that: the spec - * typed `defaultValue` as `string | number | boolean`, objectui widened the key - * to `z.any()`, and widening the key was ENOUGH — nothing else in the spec had - * an opinion about the object form. + * #4165's body warned that probing the SPEC with `{ preset }` measures + * nothing about the refinement, because the narrow `defaultValue` field type + * rejects first (`invalid_union`) and the refinement never runs. Dropping + * objectui's `z.any()` divergence gives this schema that same narrow field — + * so the same shadowing now happens HERE, and it is measured, not assumed: * - * @objectstack/spec 17.0.0-rc.6 added a whole-object refinement that refuses - * `{ preset }` BY NAME, with a message enumerating the three spellings a - * `type: 'date'` filter may use (a preset name, an ISO date, a date-macro - * token). A refinement is not a field type, so widening the key no longer - * escapes it: any derivation that CARRIES the refinement refuses the object - * form regardless of how `defaultValue` is typed. + * {"preset":"last_7_days"} → invalid_union@defaultValue: "Invalid input" + * "last_5_fortnights" → custom@defaultValue: "…is not a value a + * `type: 'date'` filter can resolve…" * - * Read the two assertions below in that light, because the obvious shorter - * version is a trap. `SpecGlobalFilterSchema.safeParse({ …, defaultValue: { - * preset } })` does fail — but it fails with `invalid_union` on the FIELD, the - * same way it failed at rc.5, and the refinement never runs. Asserting that - * would pin the OLD behaviour while reading like a pin on the new one. The - * refinement is only observable once the field admits the object, so it is - * isolated here by widening the key first — which is exactly the shape - * objectui's own dialect has. + * So the two facts need two different probes, and swapping them produces a + * test that passes for the wrong reason: * - * The conflict this leaves is a producer/consumer one with three possible - * answers (follow the spec and migrate stored dashboards; keep the divergence; - * the spec's refinement is an upstream defect), and it is filed rather than - * guessed — the deciding half belongs to the spec owner. Until it is ruled, - * `GlobalFilterSchema` is composed by spreading the spec's `.shape`, carrying - * the FIELDS by reference and leaving the refinement behind: the exact - * behaviour this schema had before rc.6, so the bump changes nothing here. The - * spread is not a preference — rc.6 closed both other doors (`.extend()` - * throws at module load, `.safeExtend()` types the three overrides as `never`), - * so some spelling had to change, and this is the one that decides nothing. - * - * This test is the tripwire on that standstill and goes red from EITHER side: - * if objectui stops accepting the object form (resolved consumer-side), or if - * the refinement is withdrawn or reworded upstream (resolved producer-side). + * - that `{ preset }` is REFUSED is pinned below on the outcome and the + * issue path, deliberately NOT on the refinement's message — demanding + * that message here would be pinning a rule that never ran; + * - that the refinement is CARRIED needs a value the field type admits, so + * it is isolated with a bogus preset STRING in the test after this one. + * That is the only assertion in this file that can go red if the + * delegation in `complex.zod.ts` is dropped. */ - it('does NOT carry the spec rc.6 refinement that refuses `{ preset }` (objectui#4165)', () => { - const stored = { field: 'created_at', type: 'date', defaultValue: { preset: 'last_7_days' } }; - - // objectui side: still accepts what its own normalizer writes. - expect(GlobalFilterSchema.safeParse(stored).success).toBe(true); - - // Spec side, refinement ISOLATED from the field type: widen `defaultValue` - // exactly as objectui's dialect does, but keep the spec's checks (that is - // what `.safeExtend` preserves). What refuses the value now can only be the - // rc.6 refinement, and asserting its MESSAGE is what separates "the spec's - // `defaultValue` is narrow" (true since forever) from "the spec has a rule - // about this exact shape" (new in rc.6, and the reason #4165 exists). - const specWithRefinement = SpecGlobalFilterSchema.safeExtend({ - defaultValue: z.any().optional(), + it('REFUSES the legacy `{ preset }` object form', () => { + const refused = GlobalFilterSchema.safeParse(legacy); + expect(refused.success).toBe(false); + expect(refused.error?.issues?.map((i) => i.path.join('.'))).toContain('defaultValue'); + // The spec refuses it the same way and at the same layer — which is the + // point of retiring the divergence: one contract, one verdict. + expect(SpecGlobalFilterSchema.safeParse(legacy).success).toBe(false); + }); + + it('carries the REFINEMENT, not just the narrow field type', () => { + // A bogus preset NAME is a string, so it clears `string | number | boolean` + // and can only be refused by the rc.6 cross-field rule. Asserting the + // spec's own message is what separates "objectui re-implemented the rule" + // from "objectui delegates to the spec": a hand-written local copy would + // keep the verdict and lose the wording, and then drift silently on the + // next spec change. + const refused = GlobalFilterSchema.safeParse({ + field: 'created_at', type: 'date', defaultValue: 'last_5_fortnights', }); - const refused = specWithRefinement.safeParse(stored); expect(refused.success).toBe(false); expect( refused.error?.issues?.some((i) => /is not a value a .*date.* filter can resolve/.test(i.message)), - 'the spec no longer refuses `{ preset }` with its rc.6 refinement message — ' + - 'either the refinement was withdrawn or its wording changed. Re-read objectui#4165 ' + - 'before touching this: the standstill it documents may be over.', + 'the refinement is no longer reaching this schema (or its message changed ' + + 'upstream). Re-read objectui#4165 before relaxing this: adopting the rule ' + + 'IS the resolution the maintainer ruled for.', ).toBe(true); + }); - // …and the bare preset NAME, which the refinement's message points authors - // at, is already legal on both sides. That is why #4165's leading candidate - // is a rewrite rather than a capability loss. - const bare = { field: 'created_at', type: 'date', defaultValue: 'last_7_days' }; - expect(specWithRefinement.safeParse(bare).success).toBe(true); - expect(SpecGlobalFilterSchema.safeParse(bare).success).toBe(true); - expect(GlobalFilterSchema.safeParse(bare).success).toBe(true); + it('leaves a NON-date filter\'s defaultValue alone', () => { + // The refinement is scoped to `type: 'date'`. Adopting it must not start + // policing a select/text filter's default. + expect(GlobalFilterSchema.safeParse({ field: 'region', type: 'select', defaultValue: 'EMEA' }).success).toBe(true); + expect(GlobalFilterSchema.safeParse({ field: 'amount', type: 'number', defaultValue: 42 }).success).toBe(true); }); - it('keeps `optionsFrom.labelField` optional', () => { + it('still applies the two surviving divergences while carrying the refinement', () => { + // The composition has to do BOTH. A version that adopted the refinement by + // simply deriving from the spec would silently take the options divergence + // away with it. expect(GlobalFilterSchema.safeParse({ - field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, + field: 'created_at', type: 'date', defaultValue: 'last_7_days', options: ['EMEA'], + optionsFrom: { object: 'users', valueField: 'id' }, }).success).toBe(true); - expect(SpecGlobalFilterSchema.safeParse({ - field: 'owner', optionsFrom: { object: 'users', valueField: 'id' }, - }).success).toBe(false); + }); + + describe('the ADR-0089 legacy-alias lift', () => { + it('turns a stored `{ preset }` declaration into something the schema accepts', () => { + // The read path's contract, end to end: refused raw, accepted lifted. + expect(GlobalFilterSchema.safeParse(legacy).success).toBe(false); + const lifted = liftLegacyGlobalFilterDefault(legacy); + const parsed = GlobalFilterSchema.safeParse(lifted); + expect(parsed.success).toBe(true); + expect((parsed as { data: { defaultValue?: unknown } }).data.defaultValue).toBe('last_7_days'); + }); + + it('is identity — same reference — when there is nothing to lift', () => { + // Load-bearing for the designer: a new object on every load would read as + // an edit and mark a pristine dashboard dirty. + expect(liftLegacyGlobalFilterDefault(canonical)).toBe(canonical); + const noDefault = { field: 'region', type: 'select' }; + expect(liftLegacyGlobalFilterDefault(noDefault)).toBe(noDefault); + }); + + it('does NOT lift a `{ preset, from, to }` value — that has no canonical spelling', () => { + // Lifting it would have to drop `from`/`to` to fit the bare-name form, + // losing data silently. It falls through to the spec's named rejection + // instead, which is the loud outcome we want. + const withBounds = { + field: 'created_at', type: 'date', + defaultValue: { preset: 'last_7_days', from: '2026-01-01', to: '2026-01-31' }, + }; + expect(liftLegacyGlobalFilterDefault(withBounds)).toBe(withBounds); + expect(GlobalFilterSchema.safeParse(withBounds).success).toBe(false); + }); + + it('lifts every entry of a dashboard document, and only when one is legacy', () => { + const legacyDoc: { type: string; globalFilters: Array> } = { + type: 'dashboard', + globalFilters: [legacy, { field: 'region' }], + }; + const liftedDoc = liftLegacyDashboardFilterDefaults(legacyDoc); + expect(liftedDoc).not.toBe(legacyDoc); + expect(liftedDoc.globalFilters[0].defaultValue).toBe('last_7_days'); + // Untouched entries keep their identity. + expect(liftedDoc.globalFilters[1]).toBe(legacyDoc.globalFilters[1]); + + const canonicalDoc = { type: 'dashboard', globalFilters: [canonical] }; + expect(liftLegacyDashboardFilterDefaults(canonicalDoc)).toBe(canonicalDoc); + const noFilters = { type: 'dashboard' }; + expect(liftLegacyDashboardFilterDefaults(noFilters)).toBe(noFilters); + }); + + it('is idempotent', () => { + const once = liftLegacyGlobalFilterDefault(legacy); + expect(liftLegacyGlobalFilterDefault(once)).toBe(once); + }); + }); + + /** + * The window's documentation is part of the deliverable, not commentary + * (maintainer ruling: "retirement window recorded at the read site"). An + * alias whose removal conditions are not written down is an alias nobody + * dares remove — which is how a "temporary" tolerance becomes permanent. + * + * Source-text pin rather than a behavioural one, because there is no runtime + * surface for a doc comment. It asks only for the three elements ADR-0089 + * requires — what the alias is, why it is lifted, and when it may go — plus + * the issue number that carries the ruling. + */ + it('records the retirement window at the read site', () => { + const readSite = resolve( + dirname(new URL(import.meta.url).pathname), + '../../../core/src/utils/dashboard-filters.ts', + ); + const source = readFileSync(readSite, 'utf8'); + for (const required of [ + 'liftLegacyGlobalFilterDefault', // the lift, imported not re-implemented + '#4165', // the ruling's issue + 'ADR-0089', // the pattern being followed + 'What it is', // what the alias is + 'Why it is lifted here rather than tolerated', // why + 'When it may be removed', // the window's end + '18.0.0', // …stated as a concrete version, not "later" + ]) { + expect(source, `the read site no longer documents: ${required}`).toContain(required); + } }); }); diff --git a/packages/types/src/dashboard-filter-alias.ts b/packages/types/src/dashboard-filter-alias.ts new file mode 100644 index 0000000000..f29ec9aea5 --- /dev/null +++ b/packages/types/src/dashboard-filter-alias.ts @@ -0,0 +1,135 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - Dashboard global-filter legacy alias (objectui#4165) + * + * The ADR-0089 alias-retirement pattern for the `{ preset: }` spelling + * of a `type: 'date'` global filter's declared `defaultValue`. + * + * Deliberately ZOD-FREE, and deliberately not in `zod/complex.zod.ts` next to + * the schema it guards: `@object-ui/types`' main entry re-exports runtime + * helpers, and pulling this one from a `*.zod.ts` module would drag the whole + * zod graph into every consumer of that entry. The schema's doc block points + * here instead. + * + * @module dashboard-filter-alias + * @packageDocumentation + */ + +/** + * Is this the LEGACY `{ preset: }` spelling of a date filter's declared + * `defaultValue`? + * + * Deliberately exact: the single key `preset`, holding a non-empty string. The + * documented alias is that shape and only that shape, so `{ preset, from, to }` + * is NOT lifted — it has no canonical spelling to be lifted TO, and dropping + * the bounds to manufacture one would lose data silently. Such a value falls + * through to the spec's own named rejection, which is the correct loud outcome. + */ +function isLegacyPresetDefault(value: unknown): value is { preset: string } { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value as object); + if (keys.length !== 1 || keys[0] !== 'preset') return false; + const preset = (value as { preset: unknown }).preset; + return typeof preset === 'string' && preset.length > 0; +} + +/** + * Lift the LEGACY `{ preset: }` spelling of a `type: 'date'` filter's + * declared `defaultValue` to the canonical bare preset NAME — the ADR-0089 + * alias-retirement pattern, applied to a value SHAPE rather than to a key + * (objectui#4165). + * + * Returns the filter UNCHANGED (same reference) when there is nothing to lift, + * so callers can use identity to detect whether an alias was present. + * + * ## What the alias is, and why it is only an alias + * + * `@objectstack/spec` 17.0.0-rc.6 put a `superRefine` on `GlobalFilterSchema` + * holding a `type: 'date'` filter's `defaultValue` to three spellings — a + * preset NAME (`last_7_days`), an ISO date (`2026-01-15`), or a date-macro + * token (`{today}`) — and refusing everything else BY NAME. `{ preset: … }` is + * not among them. + * + * That object form was never a spelling objectui WROTE to storage. Measured in + * objectui#4165: `@object-ui/core`'s `normalizeDateDefault` produces it as the + * runtime VALUE of a filter variable (`DateRangeValue`, which is what + * `DateRangeFilter` and `buildFilterCondition` read), and no code path writes a + * resolved filter def back into a dashboard document. The prose that used to + * sit on `GlobalFilterSchema` claimed the opposite ("stored dashboards carry + * that object form"), conflating declaration space with value space — that + * conflation is what #4165 was filed about. + * + * It is still a real alias, because `GlobalFilterSchema` (`zod/complex.zod.ts`) + * ACCEPTED it — `defaultValue` was widened to `z.any()` — and advertised it as + * the on-disk form from objectstack#4115 until this change. A hand-authored or + * AI-authored dashboard may legitimately carry it, and refusing those documents + * outright is exactly the breakage this window exists to avoid. + * + * ## Why a lift and not a widened schema + * + * Maintainer ruling, objectui#4165, 2026-08-11 — quoted verbatim: + * + * > 「spec stays strict — no widening。rc.6 的 refinement 成立,bare preset + * > NAME 是唯一 canonical spelling。」 + * + * Two accepted spellings for one meaning is permanent contract accretion and + * gives AI authors two forms to drift between forever. A lift converges them + * and, unlike a widened schema, can actually be retired. + * + * ## Retirement window + * + * - **Opened**: 2026-08-11, `@object-ui/types` 17.x (objectui#4165). + * - **Read sites that apply it** — both call THIS function, there is no second + * implementation: + * 1. `resolveDashboardFilterDefs` (`@object-ui/core`, `dashboard-filters.ts`) + * — every stored `globalFilters` entry passes through it before anything + * renders, so a legacy dashboard renders identically to a canonical one; + * 2. `DashboardDesignPage` (`@object-ui/plugin-designer`) — lifts the stored + * document as it enters the editable draft, so the NEXT save persists the + * canonical spelling. That is the rewrite half; without it the window + * could never close. + * - **Observability**: read site 1 warns on the console whenever it lifts, so a + * surviving legacy document is visible rather than silently tolerated + * (ADR-0078 — nothing silently inert). + * - **May be removed**: in the next MAJOR of `@object-ui/types` (18.0.0). At + * that point delete this function, its two call sites and the pins in + * `__tests__/report-chart-query-spec-parity.test.ts`; a document still + * carrying `{ preset }` then gets the spec's named rejection, which is the + * intended end state. + */ +export function liftLegacyGlobalFilterDefault(filter: T): T { + if (!filter || typeof filter !== 'object') return filter; + const defaultValue = (filter as { defaultValue?: unknown }).defaultValue; + if (!isLegacyPresetDefault(defaultValue)) return filter; + return { ...(filter as object), defaultValue: defaultValue.preset } as T; +} + +/** + * Document-level convenience over {@link liftLegacyGlobalFilterDefault}: lift + * every `globalFilters` entry of a stored dashboard. + * + * Returns the dashboard UNCHANGED (same reference) when no entry carried the + * alias — load-bearing for the designer, where a new object identity on every + * load would read as an edit and mark a pristine dashboard dirty. + */ +export function liftLegacyDashboardFilterDefaults(dashboard: T): T { + if (!dashboard || typeof dashboard !== 'object') return dashboard; + const filters = (dashboard as { globalFilters?: unknown }).globalFilters; + if (!Array.isArray(filters)) return dashboard; + let changed = false; + const lifted = filters.map((f) => { + const next = liftLegacyGlobalFilterDefault(f); + if (next !== f) changed = true; + return next; + }); + if (!changed) return dashboard; + return { ...(dashboard as object), globalFilters: lifted } as T; +} + diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 031ac8178f..9498f06557 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -70,6 +70,14 @@ export { menuItemToNavigationItem, isValidAppName, wizardDraftToAppSchema } from export { detectStatusField } from './record-semantics'; export type { StatusFieldSource } from './record-semantics'; +// Dashboard global-filter legacy alias (ADR-0089 retirement window, #4165) — +// `{ preset: }` → the canonical bare preset name. Read-site helpers, so +// they live on the main entry rather than under `/zod` (they carry no zod). +export { + liftLegacyGlobalFilterDefault, + liftLegacyDashboardFilterDefaults, +} from './dashboard-filter-alias'; + // ============================================================================ // Base Types - The Foundation // ============================================================================ diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index d3cdebd911..ea4f9b1485 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -312,9 +312,9 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. /** * Global Filter Schema — a dashboard-level filter definition, DERIVED from * `@objectstack/spec/ui` (objectstack#4115): `name`, `field`, `label`, `type`, - * `scope` and `targetWidgets` flow in **by reference**. + * `defaultValue`, `scope` and `targetWidgets` flow in **by reference**. * - * Three pinned divergences, each backed by a runtime normalizer in + * Two pinned divergences, each backed by a runtime normalizer in * `@object-ui/core`'s `dashboard-filters.ts`: * - `options` also accepts the bare-string shorthand (`options: ['EMEA', …]`) * and an object without `label`; `normalizeFilterOptions` folds both into the @@ -322,46 +322,50 @@ export const DashboardWidgetSchema = specFieldsExcept(SpecDashboardWidgetSchema. * - `optionsFrom.labelField` stays optional (it falls back to `valueField`) and * `filter` stays `z.any()` — objectui passes an ObjectQL FilterNode array * here, not the spec's `FilterCondition` envelope. - * - `defaultValue` stays `z.any()` — `normalizeDateDefault` (framework#4475) - * lifts a date preset NAME into `{ preset }`, and stored dashboards carry - * that object form, which the spec's `string | number | boolean` rejects. + * + * There used to be a third — `defaultValue` widened to `z.any()` so the + * `{ preset }` object form would validate. It was RETIRED by the maintainer + * ruling on objectui#4165 (2026-08-11): the spec stays strict, the bare preset + * name is the single canonical spelling, and the object form is handled as a + * documented legacy alias by `liftLegacyGlobalFilterDefault` + * (`../dashboard-filter-alias.ts`, which carries the retirement window) rather + * than by a permanently tolerant schema. Keeping it would have been the + * tolerant-consumer failure AGENTS.md #0.1 names: objectui green on metadata + * the platform refuses, so the designer saves and the server rejects. * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. * - * ## Spread, not `.extend` — and NOT `.safeExtend` either (objectui#4165) + * ## Composition: spread + delegated refinement (objectui#4165) * * @objectstack/spec 17.0.0-rc.6 put a refinement on `GlobalFilterSchema`, and - * that closed BOTH extension doors on a schema whose whole purpose here is to - * override three keys: + * a refined object schema in zod 4 closes every structural door this derivation + * would normally use. All three were measured on rc.6 + zod 4.4.3: * - * - `.extend()` — what this used to be — now throws outright: *"Cannot + * - `.extend()` — what this used to be — **throws at module load**: *"Cannot * overwrite keys on object schemas containing refinements. Use - * `.safeExtend()` instead."* At MODULE LOAD, taking six `@object-ui/types` - * suites down before any of them ran a test. + * `.safeExtend()` instead."* It took six `@object-ui/types` suites down + * before any of them ran a test. * - `.safeExtend()` — zod's own suggested replacement — runs, but is "safe" - * precisely in the sense that it will not let you REPLACE an existing key's - * type. It types every incompatible override as `never`, so `options`, - * `optionsFrom` and `defaultValue` — exactly the three divergences above — - * stop compiling (TS2322, `is not assignable to type 'never'`). + * precisely in that it will not let you REPLACE an existing key's type: it + * types every incompatible override as `never`. Still true with only two + * overrides left (TS2322 on BOTH `options` and `optionsFrom`), so retiring + * the `defaultValue` divergence did not re-open this door. + * - `.omit()` — the obvious way to drop the two keys before re-adding them — + * **throws** as well: *".omit() cannot be used on object schemas containing + * refinements"*. So does `.pick()`, for the same reason. * - * So the spread below is not a style choice: it is the only spelling that keeps - * this schema the shape it has been since objectstack#4115. It composes the - * spec's fields BY REFERENCE (`.shape`, so a spec field change still lands - * here) and replaces the three, which is byte-for-byte what `.extend()` - * produced before rc.6. + * What is left is to spread the spec's `.shape` (fields still flow in BY + * REFERENCE, so a spec field change lands here) and re-attach the spec's + * OBJECT-LEVEL rules by DELEGATION: re-parse the spec-owned keys through the + * spec schema itself and forward its issues. That restates none of the spec's + * grammar — the rejection message an author sees is the spec's own, and a + * refinement the spec adds LATER flows in with no change here. The two + * divergent keys are excluded from the delegated parse by construction, which + * is the whole and only exemption. * - * What it does NOT do is carry the spec's new refinement across, and that is - * deliberate and TEMPORARY. The refinement rejects `defaultValue: - * { preset: 'last_7_days' }` on a `type: 'date'` filter — the exact object form - * `@object-ui/core`'s `normalizeDateDefault` PRODUCES and stored dashboards - * carry (framework#4475), and the stated reason for the third divergence. That - * is a producer/consumer conflict, not a mechanical repair: adopting the rule - * means changing what objectui writes and migrating stored dashboards, and - * declining it permanently means objectui accepts metadata the platform - * refuses. Neither is a call to make in passing, so this preserves the status - * quo EXACTLY — nothing about this schema's behaviour changes on the rc.6 bump — - * and the ruling is tracked in objectui#4165. `__tests__/report-chart-query- - * spec-parity.test.ts` pins the gap explicitly so it cannot fade into folklore. + * Cost: one extra parse of the spec-owned subset per validation. Acceptable — + * nothing in objectui validates dashboards on a render path; this schema is a + * published contract for consumers and tooling. * * Drift guard: `__tests__/report-chart-query-spec-parity.test.ts`. */ @@ -380,7 +384,17 @@ export const GlobalFilterSchema = z.object({ labelField: z.string().optional(), filter: z.any().optional(), }).optional().describe('Dynamic option source'), - defaultValue: z.any().optional().describe('Initial value (objectui also accepts the normalized date-preset object)'), +}).superRefine((filter, ctx) => { + // Delegate every spec-owned rule (today the rc.6 date-`defaultValue` + // refinement; tomorrow whatever the spec adds) to the spec schema itself. + // `options`/`optionsFrom` are the declared divergences and are withheld — + // both are `.optional()` upstream, so omitting them is valid input. + const specOwned: Record = { ...filter }; + delete specOwned.options; + delete specOwned.optionsFrom; + const result = SpecGlobalFilterSchema.safeParse(specOwned); + if (result.success) return; + for (const issue of result.error.issues) ctx.addIssue({ ...issue }); }); /** diff --git a/scripts/check-spec-symbol-derivation.mjs b/scripts/check-spec-symbol-derivation.mjs index 63468dc5aa..dec1a3535b 100644 --- a/scripts/check-spec-symbol-derivation.mjs +++ b/scripts/check-spec-symbol-derivation.mjs @@ -111,28 +111,6 @@ const ALLOW = { "adds a key, retires one, claims an extension name, or widens `value` itself.", issue: 4115, }, - "@object-ui/types:GlobalFilterSchema": { - reason: - "Spec-derived dialect composed the same way as `SelectOptionSchema` above, and excused " + - "for the same reason: the spec's keys flow in BY REFERENCE via " + - "`SpecGlobalFilterSchema.shape`, which this guard cannot see because a const's object " + - "literal is deliberately not descended into (a spread inside `z.object({…})` is " + - "indistinguishable, to an AST pass, from a hand-typed shape). Three keys are then " + - "replaced, each a documented divergence at the declaration: `options` and `optionsFrom` " + - "(objectui's wire forms) and `defaultValue` (widened to carry the `{ preset }` object " + - "`@object-ui/core`'s `normalizeDateDefault` produces and stored dashboards hold, " + - "framework#4475). Drift guard: " + - "packages/types/src/__tests__/report-chart-query-spec-parity.test.ts. " + - "The SPELLING is new, not the dialect: this was `SpecGlobalFilterSchema.extend({…})` " + - "until @objectstack/spec 17.0.0-rc.6 put a refinement on the schema, which makes " + - "`.extend()` throw at module load and types every override as `never` under " + - "`.safeExtend()`. The spread is the only remaining spelling that preserves the shape " + - "byte-for-byte, and it is what moved the derivation out of this guard's sight — so the " + - "collision is an artifact of the forced rewrite, not a new fork (objectui#4167). " + - "The refinement itself is deliberately NOT carried across and the standstill is pinned; " + - "which side moves is a producer/consumer contract decision owned by objectui#4165.", - issue: 4167, - }, "@object-ui/types:ListViewSchema": { reason: "TS twin of the spec-derived `ListViewSchema` zod node (objectql.zod.ts), which DOES " +