diff --git a/.changeset/7352-drill-down-config-mirror.md b/.changeset/7352-drill-down-config-mirror.md new file mode 100644 index 000000000..b3d40dd2e --- /dev/null +++ b/.changeset/7352-drill-down-config-mirror.md @@ -0,0 +1,41 @@ +--- +'@object-ui/types': minor +--- + +`DrillDownConfigSchema` is the zod mirror of `DrillDownConfig`, and both +declarations that carry `drillDown` reference it — `ChartSchema` +(`zod/data-display.zod.ts`) and `ObjectDataTableSchema` (`zod/objectql.zod.ts`) +— so the published validator under `@object-ui/types/zod` reads the key for the +first time (objectui#7352). + +`DrillDownConfig` has been declared on the TypeScript face since objectui#6058 +seeded the parity ledger, and objectui#6576 declared it on a second type. No zod +mirror existed, so under `BaseSchema`'s `.passthrough()` a +`drillDown: { enabled: 'yes' }` parsed green and rode through to a widget that +reads `enabled` as truthy — `declared !== enforced` on a published surface. + +Accept-set change on the published validator, stated plainly: + +- NARROWS: a `drillDown` whose declared key holds a value outside its declared + type (`enabled: 'yes'`, `mode: 'jump'`, `maxRows: '50'`, `report: 'pipeline'`) + is now refused BY NAME on a `chart` or `object-data-table` node, where it + previously rode through untouched. +- Unchanged: every value the TypeScript declares still validates, including the + `{ enabled: true }` / `{ enabled: true, mode: 'record' }` blocks the dashboard + renderer synthesises, `report`'s two structural forms, and an inline report's + extra keys (the declaration's index signature is `.catchall(z.unknown())`). +- Output shape, worth knowing before you read a parsed `drillDown.report`: the + member is a union, and its two arms differ in what they KEEP. The inline arm + carries the declaration's index signature as `.catchall(z.unknown())`, so extra + report keys survive; the named-reference arm is a plain object, so a value that + reaches it keeps only `name` (`{ name: 'x', columns: [] }` is accepted, and + parses to `{ name: 'x' }`). Both were accepted and unvalidated before, and + neither is refused now. +- Unchanged: `PivotTableSchema.drillDown` has no zod mirror at all, and + `DataTableSchema` declares the key on neither face — both are untouched here. +- New export on `@object-ui/types/zod`: `DrillDownConfigSchema`. + +`DrillDownConfigSchema` is deliberately NOT `@objectstack/spec/ui`'s +`ChartDrillDownSchema`: that object models the chart-only subset strictly and +refuses `mode` and `report` by name, both of which are live keys on the table / +pivot / metric widgets that share `DrillDownConfig`. diff --git a/.changeset/7363-objectql-union-arms.md b/.changeset/7363-objectql-union-arms.md new file mode 100644 index 000000000..b660f6a8f --- /dev/null +++ b/.changeset/7363-objectql-union-arms.md @@ -0,0 +1,28 @@ +--- +'@object-ui/types': minor +--- + +`ObjectGallerySchema` and `ObjectDataTableSchema` are members of +`ObjectQLComponentSchema` on both faces — the TS union in `objectql.ts` and the +zod union in `zod/objectql.zod.ts` — so `AnyComponentSchema`, and with it +`validateSchema` / `safeValidateSchema` / `objectui validate`, has an arm for +`object-gallery` and `object-data-table` nodes (objectui#7363). + +PR #7355 (objectui#6576) minted both schemas beside the other `Object*Schema` +members and deliberately left the unions alone. Until now a document carrying +either node was refused as matching NO arm — exactly as before the schemas +existed — and a wrong-typed declared key on it could never be diagnosed by name. + +Accept-set change on the published validator, both directions, stated plainly: + +- WIDENS: a well-formed `object-gallery` / `object-data-table` node now + validates instead of being refused for having no arm. +- NARROWS in effect: a malformed one (`searchable: 'yes'`, `imageField: 42`, + `onRowClick` authored as JSON) is now refused BY NAME by the arm, where it was + previously refused only as "no arm matches". +- TS face: `Extract< ObjectQLComponentSchema, { type: 'object-gallery' } >` + resolves to `ObjectGallerySchema` instead of `never`; likewise for + `object-data-table`. `SchemaByType` has no in-repo consumer, and the wider + `AnySchema` union already carried `BaseSchema`, so nothing narrows there. + +No renderer behaviour changes; both nodes rendered before and render the same. diff --git a/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts new file mode 100644 index 000000000..0bfcd8deb --- /dev/null +++ b/packages/types/src/__tests__/drill-down-config-mirror-7352.test.ts @@ -0,0 +1,211 @@ +/** + * 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. + */ + +/** + * `drillDown` is validated by name wherever a mirror declares it + * (objectui#7352). + * + * `DrillDownConfig` (`../data-display.ts`) is the drill configuration five + * widgets share, and two mirrored declarations carry `drillDown?: DrillDownConfig` + * — `ChartSchema` and `ObjectDataTableSchema` (objectui#6576). Neither mirror + * knew the key: there was no `DrillDownConfigSchema`, so under `BaseSchema`'s + * `.passthrough()` a `drillDown: { enabled: 'yes' }` parsed GREEN and rode + * through to a widget that reads `enabled` as truthy. Both pairs were ledgered + * in `zod-mirror-parity.test.ts` (`UnmirroredDeclared`) as the measured debt. + * + * This file is the behaviour pin for the repair: the mirror exists, is + * exported from `@object-ui/types/zod`, is wired into both declarations, and + * REFUSES the malformed value by name where nothing refused it before. The + * ledger side (both `UnmirroredDeclared` rows gone, the new pair registered) + * is pinned by the parity file's own ratchet. + * + * ⚠️ `PivotTableSchema.drillDown` is NOT covered here: that declaration has no + * zod mirror at all, so it sits in no ledger. The mirror minted here is the home + * that key will use whenever the pivot pair is mirrored (a separate card). + * + * ⚠️ Not the spec's `ChartDrillDownSchema`, deliberately: `@objectstack/spec/ui` + * models the CHART-ONLY subset (`enabled` / `filter` / `title` / `target` / + * `columns` / `maxRows`) as a strict object that refuses `mode` and `report` by + * name, and both of those are real keys on the table / pivot / metric widgets + * that share `DrillDownConfig`. Referencing it would make the published + * validator refuse what the published TypeScript declares — the class this + * card closes, in the other direction. + */ +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + safeValidateSchema, + DrillDownConfigSchema, + ChartSchema, + ObjectDataTableSchema, +} from '../zod/index.zod.js'; +import type { DrillDownConfig } from '../data-display.js'; + +/* ── Runtime helpers ───────────────────────────────────────────────────────── */ + +/** Every issue path in the tree, per-arm `errors` included (see the objectui#7363 pin). */ +function issuePaths(result: { success: true } | { success: false; error: { issues: readonly z.core.$ZodIssue[] } }): string[] { + if (result.success) return []; + const out: string[] = []; + const walk = (issues: readonly z.core.$ZodIssue[]) => { + for (const issue of issues) { + out.push(issue.path.map(String).join('.')); + const nested = (issue as { errors?: readonly (readonly z.core.$ZodIssue[])[] }).errors; + if (nested) for (const arm of nested) walk(arm); + } + }; + walk(result.error.issues); + return out; +} + +/** A chart document that validates on its own, so a red run is about `drillDown`. */ +const chart = (drillDown: unknown) => ({ + type: 'chart', + chartType: 'bar', + series: [{ name: 'revenue' }], + drillDown, +}); +const table = (drillDown: unknown) => ({ type: 'object-data-table', objectName: 'contact', drillDown }); + +/* ── The values hosts actually synthesise ──────────────────────────────────── */ + +/** + * Read from the renderers, not invented: `DashboardRenderer.tsx` writes + * `{ enabled: true }` for drillable charts and `{ enabled: true, mode: 'record' }` + * for object-backed tables; `ObjectMetricWidget.tsx` writes + * `{ enabled: true, mode: 'record', target: 'dialog' }`; the drill tests author + * `{ enabled: false }` and `{ enabled: true, mode: 'filter' }`. `report` takes the + * two structural forms `DrillDownConfig` declares. + */ +const ACCEPTED: Array<[string, DrillDownConfig]> = [ + ['the empty block', {}], + ['enabled', { enabled: true }], + ['disabled', { enabled: false }], + ['record mode', { enabled: true, mode: 'record' }], + ['filter mode', { enabled: true, mode: 'filter' }], + ['record mode in a dialog', { enabled: true, mode: 'record', target: 'dialog' }], + ['navigate target', { target: 'navigate' }], + ['the full drill-through shape', { + enabled: true, + title: '${event.rowLabel}', + filter: { status: '${event.rowKey}', owner: 42 }, + columns: ['name', 'amount'], + maxRows: 50, + }], + ['an inline report', { report: { name: 'pipeline', objectName: 'opportunity', type: 'summary', columns: [] } }], + ['an inline report carrying extra report keys', { + report: { name: 'pipeline', objectName: 'opportunity', columns: [{ field: 'amount' }], groupBy: ['stage'] }, + }], + ['a named report reference', { report: { name: 'pipeline' } }], + // ⚠️ MEASURED, not assumed, and it moved here from the refusal table below. The + // declaration's second arm is `{ name: string }`, and THIS value reaches it: the + // inline arm wants `objectName`, so the value falls through to the reference arm, + // which a string `name` satisfies. TypeScript agrees for the same structural + // reason, and its excess-property check passes on this literal because `columns` + // is a member of the OTHER arm — ⚠️ that last part is what makes the acceptance + // shape-specific rather than universal: it is not "any extra key rides along". + // This entry is annotated `DrillDownConfig`, so `tsc -p tsconfig.test.json` is the + // witness — if the declaration ever refused it, this line stops compiling. A mirror + // that refused it would be NARROWER than the declaration, the drift class this + // ledger exists to stop, in the direction that hurts authors. + // + // ⚠️ ACCEPTED is not "unchanged": the reference arm is a plain `z.object`, so it + // STRIPS what it does not declare, and the parsed output here is `{ name }` — the + // `columns` is gone (measured: `{ name, columns: [] }` parses to `{ name }`, while + // the full inline shape below keeps every extra key through `.catchall`). The test + // right below this table pins that surviving half. Consumers read `report` after + // parsing, so the two arms differ in output, not only in acceptance. + ['an inline report missing objectName, which the reference arm accepts', { report: { name: 'pipeline', columns: [] } }], +]; + +/** Each is a DECLARED key with a value outside its declared type. */ +const REFUSED: Array<[string, unknown, string]> = [ + ['enabled as a string', { enabled: 'yes' }, 'enabled'], + ['an unknown mode', { mode: 'jump' }, 'mode'], + ['an unknown target', { target: 'popup' }, 'target'], + ['title as a number', { title: 42 }, 'title'], + ['filter as a string', { filter: 'status eq open' }, 'filter'], + ['columns as a string', { columns: 'name' }, 'columns'], + ['maxRows as a string', { maxRows: '50' }, 'maxRows'], + ['report as a bare string', { report: 'pipeline' }, 'report'], + ['a report reference with a non-string name', { report: { name: 42 } }, 'report'], +]; + +describe('objectui#7352 — DrillDownConfigSchema is the zod mirror of DrillDownConfig', () => { + it('is exported from the /zod barrel and declares every key the TS interface declares', () => { + expect(DrillDownConfigSchema).toBeDefined(); + expect(Object.keys(DrillDownConfigSchema.shape).sort()).toEqual( + ['columns', 'enabled', 'filter', 'maxRows', 'mode', 'report', 'target', 'title'], + ); + }); + + it.each(ACCEPTED)('accepts %s', (_label, value) => { + const r = DrillDownConfigSchema.safeParse(value); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it('keeps an inline report\'s extra keys — the declaration has an index signature', () => { + const r = DrillDownConfigSchema.safeParse({ + report: { name: 'pipeline', objectName: 'opportunity', columns: [], groupBy: ['stage'] }, + }); + expect(r.success).toBe(true); + expect(r.success && (r.data.report as Record).groupBy).toEqual(['stage']); + }); + + it.each(REFUSED)('refuses %s, naming the key', (_label, value, key) => { + const r = DrillDownConfigSchema.safeParse(value); + expect(r.success).toBe(false); + expect(issuePaths(r).some((p) => p === key || p.startsWith(`${key}.`))).toBe(true); + }); +}); + +describe('objectui#7352 — both declaring mirrors read the key', () => { + it('ChartSchema and ObjectDataTableSchema declare drillDown through the shared mirror', () => { + expect(ChartSchema.shape.drillDown).toBeDefined(); + expect(ObjectDataTableSchema.shape.drillDown).toBeDefined(); + }); + + it.each(ACCEPTED)('a chart carrying %s validates', (_label, value) => { + const r = safeValidateSchema(chart(value)); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it.each(ACCEPTED)('an object-data-table carrying %s validates', (_label, value) => { + const r = safeValidateSchema(table(value)); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it('the card\'s own repro is refused where it used to parse green: chart', () => { + // Under `.passthrough()` this parsed green and the widget read `'yes'` as + // truthy. `.success === false` is the whole leg here — it was TRUE before. + const r = safeValidateSchema(chart({ enabled: 'yes' })); + expect(r.success).toBe(false); + expect(issuePaths(r)).toContain('drillDown.enabled'); + }); + + it('the card\'s own repro is refused BY NAME on object-data-table', () => { + // This node was refused before too — for having NO arm at all (objectui#7363). + // The by-name path is what did not exist. + const r = safeValidateSchema(table({ enabled: 'yes' })); + expect(r.success).toBe(false); + expect(issuePaths(r)).toContain('drillDown.enabled'); + }); + + it.each(REFUSED)('a chart carrying %s is refused under drillDown', (_label, value, key) => { + const r = safeValidateSchema(chart(value)); + expect(r.success).toBe(false); + expect(issuePaths(r).some((p) => p === `drillDown.${key}` || p.startsWith(`drillDown.${key}.`))).toBe(true); + }); + + it('the mirror does not reach beyond the two declaring pairs: a plain data-table has no drillDown arm', () => { + // `DataTableSchema` does not declare `drillDown` on either face; a value there + // still rides through on `.passthrough()`, unchanged by this card. + const r = safeValidateSchema({ type: 'data-table', columns: [], data: [], drillDown: { enabled: 'yes' } }); + expect(r.success).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/objectql-union-arms-7363.test.ts b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts new file mode 100644 index 000000000..731e7d995 --- /dev/null +++ b/packages/types/src/__tests__/objectql-union-arms-7363.test.ts @@ -0,0 +1,121 @@ +/** + * 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-gallery` / `object-data-table` nodes reach a validation arm + * (objectui#7363). + * + * PR #7355 (objectui#6576) minted `ObjectGallerySchema` and + * `ObjectDataTableSchema` on both faces and deliberately left + * `ObjectQLComponentSchema` alone, so `AnyComponentSchema` — and with it + * `validateSchema` / `safeValidateSchema` / `objectui validate` — had no arm for + * either node type. A document carrying one was refused as matching NO arm, + * exactly as before the schemas existed, and a wrong-typed key on such a node + * could never be diagnosed by name. + * + * Two legs per node type, because a pass alone proves nothing — the shape + * `safe-validate-corpus-6318.test.ts` uses for `code-editor` / `bar-chart`: + * + * 1. a minimal document validates through the published entry point; + * 2. the arm is a real declaration: a wrong-typed DECLARED key on the node is + * refused, and the refusal names that key. `BaseSchema` is `.passthrough()`, + * so an UNKNOWN key would prove nothing; every probe below is a key the + * mirror declares. Before the arms existed the same documents were refused + * too — for the wrong reason, with no arm naming the key — which is why the + * BY-NAME half is the load-bearing one, not `.success === false`. + * + * The TS face is pinned beside it: `ObjectQLComponentSchema` narrows to each + * declaration by its discriminant, instead of to `never`. + */ +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { safeValidateSchema, ObjectQLComponentSchema as ObjectQLComponentZod } from '../zod/index.zod.js'; +import type { ObjectQLComponentSchema, ObjectGallerySchema, ObjectDataTableSchema } from '../objectql.js'; + +/* ── Type-level pins: the TS union narrows by discriminant ─────────────────── */ + +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** Was `never` while the union had ten arms. */ +export type assertionGalleryIsAnArm = + Expect< Equal< Extract< ObjectQLComponentSchema, { type: 'object-gallery' } >, ObjectGallerySchema > >; +export type assertionDataTableIsAnArm = + Expect< Equal< Extract< ObjectQLComponentSchema, { type: 'object-data-table' } >, ObjectDataTableSchema > >; + +/* ── Runtime helpers ───────────────────────────────────────────────────────── */ + +/** + * Every issue path in the tree, INCLUDING the per-arm `errors` a Zod 4 + * `invalid_union` hangs off its top-level issue (objectui#7004 measured that + * shape: the root reports one `invalid_union` at `[]` and the arms' real + * diagnoses live in `errors[i]`). A node with no arm produces paths of `type` + * only — every arm fails on its literal — so a DECLARED key's path appearing + * here is proof that an arm accepted the discriminant and read the key. + */ +function issuePaths(result: ReturnType): string[] { + if (result.success) return []; + const out: string[] = []; + const walk = (issues: readonly z.core.$ZodIssue[]) => { + for (const issue of issues) { + out.push(issue.path.map(String).join('.')); + const nested = (issue as { errors?: readonly (readonly z.core.$ZodIssue[])[] }).errors; + if (nested) for (const arm of nested) walk(arm); + } + }; + walk(result.error.issues); + return out; +} + +describe('objectui#7363 — the two objectui#6576 schemas are arms of the ObjectQL union', () => { + it('the zod union carries both literals', () => { + const literals = ObjectQLComponentZod.options.map( + (arm) => (arm.shape.type as z.ZodLiteral).value, + ); + expect(literals).toContain('object-gallery'); + expect(literals).toContain('object-data-table'); + // The ten arms PR #7355 left in place are all still there — this is an + // addition, not a reshuffle. + expect(literals).toHaveLength(12); + }); + + it.each([ + ['object-gallery', { type: 'object-gallery' }], + ['object-gallery with its keys', { type: 'object-gallery', objectName: 'contact', imageField: 'photo', titleField: 'name' }], + ['object-data-table', { type: 'object-data-table' }], + ['object-data-table with its keys', { type: 'object-data-table', objectName: 'contact', searchable: true, pagination: false, dataProvider: { provider: 'objectql', object: 'contact' } }], + ])('a minimal %s document validates through safeValidateSchema', (_label, doc) => { + const r = safeValidateSchema(doc); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues, null, 2)).toBe(true); + }); + + it.each([ + ['object-gallery', 'imageField', { type: 'object-gallery', imageField: 42 }], + ['object-data-table', 'searchable', { type: 'object-data-table', searchable: 'yes' }], + ['object-data-table', 'dataProvider.provider', { type: 'object-data-table', dataProvider: { provider: 42 } }], + ])('a wrong-typed declared key on %s is refused BY NAME at %s', (_type, path, doc) => { + // Probe keys are ones NO OTHER arm declares: measured on the ten-arm tree, + // `data` and `className` were already named by a sibling arm's issues, so + // they could not tell "this arm read it" from "some arm read it". + const r = safeValidateSchema(doc); + expect(r.success).toBe(false); + // The load-bearing half: an arm accepted the discriminant and diagnosed + // the key. With no arm, every path here is `type`. + expect(issuePaths(r)).toContain(path); + }); + + it('the runtime-slot refusal on object-data-table now reaches the author (objectui#6124 shape)', () => { + // `onRowClick` is refused BY NAME by the mirror (`handlerKeyRefusal`); until + // the arm existed that refusal was unreachable — the node never got past + // `type`. + const r = safeValidateSchema({ type: 'object-data-table', onRowClick: 'handler' }); + expect(r.success).toBe(false); + expect(issuePaths(r)).toContain('onRowClick'); + }); +}); diff --git a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts index 14279f5e7..4da024fc5 100644 --- a/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts +++ b/packages/types/src/__tests__/widget-schema-anchors-6576.test.ts @@ -171,11 +171,14 @@ const WIDGETS = [ /** The dashboard plugin registers its widgets from the barrel, not the widget file. */ registrationFile: 'packages/plugin-dashboard/src/index.tsx', mirror: ObjectDataTableMirror, - // `drillDown` is declared on the TS face and ledgered as unmirrored in + // `drillDown` was listed here while it was ledgered as unmirrored in // `zod-mirror-parity.test.ts` (`UnmirroredDeclared`, LOCAL — the same - // reading as `ChartSchema.drillDown`): `DrillDownConfig` has no zod mirror - // in this package, and minting one is a new export outside #6576's ruling. - unmirroredReads: ['drillDown'] as readonly string[], + // reading as `ChartSchema.drillDown`). objectui#7352 minted + // `DrillDownConfigSchema` and wired it into this mirror, so the key is now + // DECLARED by the mirror and needs no exception: the census below reads it + // out of `mirror.shape`. An exception left standing here would be a hole — + // it widens `declared` for a key nothing checks any more. + unmirroredReads: [] as readonly string[], /** The two objectui#6914 casts the declaration makes unnecessary. */ retiredCasts: ['schema.drillDown as DrillDownConfig', '(schema as any).onRowClick'] as readonly string[], }, diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index dcb8ecfe0..5dada1a13 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -28,7 +28,7 @@ * * It reads `.shape` and not `keyof z.input` because that spelling is * vacuous — `.passthrough()` collapses the inferred key union to bare `string`. - * `assertionNoVacuousEntry` below pins that for all 160 entries at once. + * `assertionNoVacuousEntry` below pins that for all 155 entries at once. * * ## What is registered * @@ -53,9 +53,21 @@ * framing, and objectui#6058's own dispatch, both inherited "163"). On a file whose * entire subject is measurement that is worth stating explicitly: * - * - **160 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` - * already pins equal to `keyof Declared`. Nothing asserts it against a written - * number, so this line is prose and can rot; the pin that cannot is the one + * - **155 pairs** — `Object.keys(MIRRORS).length`, which `assertionRegistryHalvesAgree` + * already pins equal to `keyof Declared`. 154 until objectui#7352 registered + * `data-display.zod.ts#DrillDownConfigSchema` — a nested config mirror paired with + * the local `DrillDownConfig`, the `ObjectMapConfigSchema` precedent — which + * carries no ledger entry. ⚠️ **This line rotted twice and was re-derived at + * objectui#7352 contract review.** Its history, measured by running the same walk over + * this file at each revision rather than by reading the prose: `4ca30d044` wrote + * "160" when `MIRRORS` held **163**; `d88e20f55` (objectui#7432) took the registry to + * **154** without touching the sentence; objectui#7352 then added its 1 to the stale + * baseline and wrote 161. Two independent derivations agree on 155 today — a + * TypeScript AST walk counting `PropertyAssignment` nodes in the `MIRRORS` + * initializer, and a line-oriented parse of the same block — and they agree on + * the three historical figures above. ⛔ Do not add a delta to this number; count + * the registry. Nothing asserts it against a written + * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. * - **40 entries** in `KnownDrift`, **57 keys** across them — 40 / 56 until * objectui#7104 declared `AlertDialogSchema.onAction`, the action button's @@ -75,7 +87,11 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **15 entries** in `UnmirroredDeclared`, **96 keys** across them. It read 17 / 98 + * - **13 entries** in `UnmirroredDeclared`, **94 keys** across them — 15 / 96 until + * objectui#7352 MIRRORED both `drillDown` rows at once (`ChartSchema` and + * `ObjectDataTableSchema`, each the entry's whole content, so both entries went): + * the ledger's second and third shrink by REPAIR, on the route objectui#6639 opened. + * It read 17 / 98 * between objectui#6576, which SEEDED the new `ObjectDataTableSchema` pair with its * one measured key `drillDown` (a pair born ledgered, not growth on an existing * one), and objectui#7129, which RETIRED `DetailViewSectionSchema.hideEmpty` — @@ -91,16 +107,18 @@ * meaning — the comparable figure is 95 + 1 mirrored + 2 retired + 23 * reclassified. The full statement is on that ledger. * - **7 entries** in `RuntimeOnlyDeclared`, **24 keys** across them. Six of the - * seven are a subset of the 15 pairs above; `TreeViewSchema` is NOT — it is + * seven are a subset of the 13 pairs above; `TreeViewSchema` is NOT — it is * the first pair whose ONLY ledger entry is a runtime-only one - * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), so the - * "no entry in either" population dropped by one to 141 — went to 142 when - * objectui#6576 added two pairs, one of them ledgered, went to 143 when - * objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key, and - * stands at **144** since objectui#7623 retired `DashboardComponentSchema`'s - * only UNMIRRORED one (that pair keeps its `KnownDrift` entry — this - * population counts entries in the two unmirrored ledgers). - * - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of. + * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), which is why + * the union of the two unmirrored ledgers is **14** pairs and not 13. + * - **141 pairs with no entry in either** unmirrored ledger — 155 − 14, measured, + * not stepped. ⚠️ This line used to carry a running chain of deltas (141 → 142 → + * 143 → 144 → 147, one per card). Every one of those was computed against the + * stale pair count above, so they were arithmetic on a wrong base and are NOT + * re-derivable from this file; objectui#7352 contract review replaced the chain with + * the measurement. ⛔ Do not restart the chain — subtract the union from the + * registry count, both read from the file. + * - 155 − 40 = **115**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves * @@ -121,7 +139,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 40 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 40 of the 155 pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -157,7 +175,7 @@ import { AppActionSchema, AppComponentSchema, NavigationAreaSchema } from '../zo import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema } from '../zod/base.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, DeclarativeKanbanCardSchema, DeclarativeKanbanColumnSchema, DeclarativeKanbanSchema } from '../zod/complex.zod.js'; import { ActionCallbackSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; -import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeViewSchema } from '../zod/data-display.zod.js'; +import { AlertSchema, AvatarSchema, BadgeSchema, BarChartSchema, ChartDataSeriesSchema, ChartSchema, DataTableSchema, DrillDownConfigSchema, HtmlSchema, KbdSchema, ListItemSchema, ListSchema, MarkdownSchema, StaticTableColumnSchema, StatisticSchema, TableColumnSchema, TableSchema, TimelineEventSchema, TimelineSchema, TreeViewSchema } from '../zod/data-display.zod.js'; import { AccordionItemSchema, AccordionSchema, CollapsibleSchema, ToggleGroupItemSchema, ToggleGroupSchema } from '../zod/disclosure.zod.js'; import { EmptySchema, LoadingSchema, ProgressSchema, SkeletonSchema, SonnerSchema, SpinnerSchema, ToasterSchema, ToastSchema } from '../zod/feedback.zod.js'; import { ButtonSchema, CalendarSchema, CheckboxSchema, CodeEditorSchema, ComboboxOptionSchema, ComboboxSchema, CommandGroupSchema, CommandItemSchema, CommandSchema, DatePickerSchema, FieldConditionSchema, FieldConstraintsSchema, FileUploadSchema, FormFieldSchema, FormSchema, InputOTPSchema, InputSchema, LabelSchema, RadioGroupSchema, RadioOptionSchema, SelectOptionSchema, SelectSchema, SliderSchema, SwitchSchema, TextareaSchema, ToggleSchema } from '../zod/form.zod.js'; @@ -173,7 +191,7 @@ import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, DeclarativeKanbanCard as Ts_KanbanCard, DeclarativeKanbanColumn as Ts_KanbanColumn, DeclarativeKanbanSchema as Ts_KanbanSchema } from '../complex'; import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer'; import type { ActionCallback as Ts_ActionCallback, CRUDDialogSchema as Ts_CRUDDialogSchema, DetailSchema as Ts_DetailSchema } from '../crud'; -import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; +import type { AlertSchema as Ts_AlertSchema, AvatarSchema as Ts_AvatarSchema, BadgeSchema as Ts_BadgeSchema, BarChartSchema as Ts_BarChartSchema, ChartDataSeries as Ts_ChartDataSeries, ChartSchema as Ts_ChartSchema, DataTableSchema as Ts_DataTableSchema, DrillDownConfig as Ts_DrillDownConfig, HtmlSchema as Ts_HtmlSchema, KbdSchema as Ts_KbdSchema, ListItem as Ts_ListItem, ListSchema as Ts_ListSchema, MarkdownSchema as Ts_MarkdownSchema, StaticTableColumn as Ts_StaticTableColumn, StatisticSchema as Ts_StatisticSchema, TableColumn as Ts_TableColumn, TableSchema as Ts_TableSchema, TimelineEvent as Ts_TimelineEvent, TimelineSchema as Ts_TimelineSchema, TreeViewSchema as Ts_TreeViewSchema, BreadcrumbItem as Ts_BreadcrumbItem, BreadcrumbSchema as Ts_BreadcrumbSchema } from '../data-display'; import type { AccordionItem as Ts_AccordionItem, AccordionSchema as Ts_AccordionSchema, CollapsibleSchema as Ts_CollapsibleSchema, ToggleGroupItem as Ts_ToggleGroupItem, ToggleGroupSchema as Ts_ToggleGroupSchema } from '../disclosure'; import type { EmptySchema as Ts_EmptySchema, LoadingSchema as Ts_LoadingSchema, ProgressSchema as Ts_ProgressSchema, SkeletonSchema as Ts_SkeletonSchema, SonnerSchema as Ts_SonnerSchema, SpinnerSchema as Ts_SpinnerSchema, ToasterSchema as Ts_ToasterSchema, ToastSchema as Ts_ToastSchema } from '../feedback'; import type { ButtonSchema as Ts_ButtonSchema, CalendarSchema as Ts_CalendarSchema, CheckboxSchema as Ts_CheckboxSchema, CodeEditorSchema as Ts_CodeEditorSchema, ComboboxOption as Ts_ComboboxOption, ComboboxSchema as Ts_ComboboxSchema, CommandGroup as Ts_CommandGroup, CommandItem as Ts_CommandItem, CommandSchema as Ts_CommandSchema, DatePickerSchema as Ts_DatePickerSchema, FieldCondition as Ts_FieldCondition, FieldValidationRules as Ts_FieldValidationRules, FileUploadSchema as Ts_FileUploadSchema, FormField as Ts_FormField, FormSchema as Ts_FormSchema, InputOTPSchema as Ts_InputOTPSchema, InputSchema as Ts_InputSchema, LabelSchema as Ts_LabelSchema, RadioGroupSchema as Ts_RadioGroupSchema, RadioOption as Ts_RadioOption, SelectOption as Ts_SelectOption, SelectSchema as Ts_SelectSchema, SliderSchema as Ts_SliderSchema, SwitchSchema as Ts_SwitchSchema, TextareaSchema as Ts_TextareaSchema, ToggleSchema as Ts_ToggleSchema } from '../form'; @@ -286,7 +304,7 @@ export type ReconcileAgainstLedger< K, Measured, Recorded > = export type assertionRatchetAcceptsAgreement = Expect< Equal< ReconcileAgainstLedger< 'p', 'a', 'a' >, never > >; -/** …and so does a clean pair with no entry, which is the case for 143 of the 160. */ +/** …and so does a clean pair with no entry, which is the case for 141 of the 155. */ export type assertionRatchetAcceptsCleanPair = Expect< Equal< ReconcileAgainstLedger< 'p', never, never >, never > >; @@ -403,6 +421,7 @@ const MIRRORS = { 'data-display.zod.ts#ChartDataSeriesSchema': ChartDataSeriesSchema, 'data-display.zod.ts#ChartSchema': ChartSchema, 'data-display.zod.ts#DataTableSchema': DataTableSchema, + 'data-display.zod.ts#DrillDownConfigSchema': DrillDownConfigSchema, 'data-display.zod.ts#HtmlSchema': HtmlSchema, 'data-display.zod.ts#KbdSchema': KbdSchema, 'data-display.zod.ts#BarChartSchema': BarChartSchema, @@ -561,6 +580,7 @@ interface Declared { 'data-display.zod.ts#ChartDataSeriesSchema': Ts_ChartDataSeries; 'data-display.zod.ts#ChartSchema': Ts_ChartSchema; 'data-display.zod.ts#DataTableSchema': Ts_DataTableSchema; + 'data-display.zod.ts#DrillDownConfigSchema': Ts_DrillDownConfig; 'data-display.zod.ts#HtmlSchema': Ts_HtmlSchema; 'data-display.zod.ts#KbdSchema': Ts_KbdSchema; 'data-display.zod.ts#BarChartSchema': Ts_BarChartSchema; @@ -922,9 +942,9 @@ interface KnownDrift { * invites an author to write and the published validator has never heard of. * * ## ⚠️ READ THIS BEFORE QUOTING THE NUMBER — 121 became 98 by RECLASSIFICATION, - * ## then 97 by the first REPAIR, then 96 by RETIREMENT + * ## then 97 by the first REPAIR, then 96 by RETIREMENT, then 94 by REPAIR again * - * objectui#6058 seeded this ledger at **121 keys**. It records **96**, and the + * objectui#6058 seeded this ledger at **121 keys**. It records **94**, and the * movements are different facts. objectui#6152 measured the 23 callback-shaped * (`on*`) keys and ruled that mirroring is the wrong remedy for every one of them; * they moved, intact and still pinned, to `RuntimeOnlyDeclared` below — ⛔ nothing @@ -934,12 +954,17 @@ interface KnownDrift { * first shrink by repair. Then objectui#7129 and objectui#7623 each RETIRED a key by * deleting the DECLARATION (`DetailViewSectionSchema.hideEmpty`; * `DashboardComponentSchema.title`) — shrinks by removal, which is neither a repair - * of the mirror nor a waiver: the key stops being offered to authors at all. + * of the mirror nor a waiver: the key stops being offered to authors at all. Then + * objectui#7352 MIRRORED the two `drillDown` keys (`ChartSchema`, + * `ObjectDataTableSchema`) by minting the `DrillDownConfigSchema` both entries named + * as their remedy — the second and third repairs, and the first to close two entries + * in one change. * - * So: **96 is the mirroring debt; 96 − 1 seeded + 1 mirrored + 2 retired + 23 + * So: **94 is the mirroring debt; 94 − 1 seeded + 3 mirrored + 2 retired + 23 * reclassified is what "121" used to mean** (the seed is objectui#6576's - * `ObjectDataTableSchema.drillDown`, which was never part of the 121). A card that - * cites 121 as the size of the mirroring problem, or 98/97/96 as a shrink from it, + * `ObjectDataTableSchema.drillDown`, which was never part of the 121 — and which + * objectui#7352 has since repaired). A card that + * cites 121 as the size of the mirroring problem, or 98/97/96/94 as a shrink from it, * is wrong in both directions. objectui#6141 is the * standing example of what a silently moved count costs — it is why this paragraph * is in the ledger rather than in a commit message. @@ -954,7 +979,7 @@ interface KnownDrift { * is being let through, because there was no such thing. Seeding takes the count of * VISIBLE, RATCHETED facts from 0 to 121 and installs a floor: the problem cannot * grow while they are worked off, and a new declared-but-unmirrored key on any of - * the 160 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — + * the 155 pairs reddens immediately (`assertionRatchetRejectsFreshDrift`) — * including a callback-shaped one, which reddens until it is filed in * `RuntimeOnlyDeclared` (`assertionSplitLedgerRejectsFreshCallback`). * @@ -991,9 +1016,10 @@ interface KnownDrift { * spec schema does not model, which is objectui#2231's unification question and * NOT a local mirror edit. They are marked, not exempted: exempting them in the * instrument would re-blind exactly the pairs objectui#5927 leaned on hardest. - * - **LOCAL (12 entries, 83 keys)** — plain omissions from a hand-written mirror. + * - **LOCAL (11 entries, 82 keys)** — plain omissions from a hand-written mirror. * It was 13 / 84 until objectui#7129 RETIRED `DetailViewSectionSchema.hideEmpty`, - * a shrink by removing the DECLARATION rather than by mirroring it. + * a shrink by removing the DECLARATION rather than by mirroring it, and 12 / 83 + * until objectui#7352 MIRRORED `ChartSchema.drillDown` — its whole entry. * * ⚠️ Both counts moved with the reclassification: the spec-derived side lost * `ObjectViewSchema.onNavigate` (14 → 13) and the local side lost the other 22 @@ -1002,10 +1028,11 @@ interface KnownDrift { * reclassification — every affected pair kept keys here — so the split read 16 * entries / 97 keys after it. Three later changes moved the entry count itself: * objectui#6576 SEEDED `ObjectDataTableSchema` (a 17th entry, in neither half - * above), objectui#7129 RETIRED an entry from the LOCAL half, and objectui#7623 - * RETIRED one from the SPEC-DERIVED half (13 → 12 keys there). The ledger now - * totals **15 entries / 96 keys** — 2 / 12 spec-derived, 12 / 83 local, plus the - * seeded pair. + * above), objectui#7129 RETIRED an entry from the LOCAL half, objectui#7623 + * RETIRED one from the SPEC-DERIVED half (13 → 12 keys there), and objectui#7352 + * MIRRORED two — the LOCAL `ChartSchema` entry and the seeded `ObjectDataTableSchema` + * one. The ledger now totals **13 entries / 94 keys** — 2 / 12 spec-derived, + * 11 / 82 local; the seeded pair is no longer among them. * * ## How this was measured, and the trap that makes the number hard to get * @@ -1051,8 +1078,14 @@ interface UnmirroredDeclared { * routes the same way. */ 'complex.zod.ts#DashboardWidgetSchema': 'pagination' | 'searchable'; - /** LOCAL. Declared in `../data-display.ts`, absent from the mirror. */ - 'data-display.zod.ts#ChartSchema': 'drillDown'; + // `data-display.zod.ts#ChartSchema` recorded `drillDown` here (LOCAL) from + // objectui#6058's seeding until objectui#7352 MIRRORED it: `DrillDownConfigSchema` + // (`data-display.zod.ts`, a registered pair of its own above) now restates + // `DrillDownConfig` key for key, and `ChartSchema.drillDown` references it. A shrink + // by REPAIR — the objectui#6639 route, the ledger's second and third use of it (this + // entry and `ObjectDataTableSchema`'s below went in the same change) — not a + // reclassification and not a retirement: the key is still declared, still authorable, + // and is now enforced. The pair holds no entry in either unmirrored ledger. /** * LOCAL, and still the largest single entry at 17. It was 29: the twelve `on*` keys * are in `RuntimeOnlyDeclared` below (objectui#6152). `rowActions` is in @@ -1082,18 +1115,13 @@ interface UnmirroredDeclared { 'form.zod.ts#LabelSchema': 'content'; /** LOCAL. */ 'navigation.zod.ts#PaginationSchema': 'currentPage'; - /** - * LOCAL — a SEED, not growth. This pair was minted by objectui#6576 (folding - * #6914, which found the widget reading `drillDown` behind a cast and declaring - * it nowhere); the declaration now carries it, and the mirror does not, for the - * same reason `ChartSchema.drillDown` above is here: `DrillDownConfig` has no - * zod mirror in this package, and minting one is a new export outside that - * ruling. The shrink-only rule governs keys that APPEAR on registered pairs; - * a pair born with its measured debt written down is the seeding discipline - * objectui#6058 established, applied once. Remedy when ruled: a paired - * `DrillDownConfigSchema` mirror, which shrinks THIS entry and `ChartSchema`'s. - */ - 'objectql.zod.ts#ObjectDataTableSchema': 'drillDown'; + // `objectql.zod.ts#ObjectDataTableSchema` recorded `drillDown` here (LOCAL) as a + // SEED — the pair was minted by objectui#6576 with its measured debt written down, + // and the entry named its own remedy: "a paired `DrillDownConfigSchema` mirror, + // which shrinks THIS entry and `ChartSchema`'s". objectui#7352 was that ruling and + // did exactly that, so both rows are gone together. The pair keeps its `KnownDrift` + // entry above (`onRowClick`, a runtime slot the mirror refuses by name) and records + // nothing here. /** * LOCAL, and still the second-largest at 21. It was 26: the five `on*` keys are in * `RuntimeOnlyDeclared` below (objectui#6152). ⚠️ `submitHandler` is NOT among them @@ -1149,15 +1177,19 @@ interface UnmirroredDeclared { * ## ⚠️ THIS IS WHERE 23 KEYS WENT — a RECLASSIFICATION, not a fix * * `UnmirroredDeclared` above was seeded at **121 keys** by objectui#6058. It records - * **97** today: these 23 moved here whole, and `ObjectGridSchema.title` was later - * MIRRORED by objectui#6639 — the move recorded HERE repaired nothing. ⛔ Nothing was + * **94** today: these 23 moved here whole, and three keys were later MIRRORED + * (`ObjectGridSchema.title` by objectui#6639; `ChartSchema.drillDown` and + * `ObjectDataTableSchema.drillDown` by objectui#7352) while two were RETIRED by + * deleting the declaration (objectui#7129, objectui#7623) — the move recorded HERE + * repaired nothing. ⛔ Nothing was * mirrored by it, no declaration was removed, no defect was repaired and nothing was * waived: the same 23 facts are still measured, still declared-but-unmirrored, still * reconciled against the same measurement — under a different remedy. * - * **97 is the mirroring debt; 97 + 1 mirrored + 23 reclassified is what "121" used - * to mean.** Cite it that way. objectui#6141 is the standing example of what a - * silently moved count costs. + * **94 is the mirroring debt; 94 − 1 seeded + 3 mirrored + 2 retired + 23 + * reclassified is what "121" used to mean.** Cite it that way. objectui#6141 is the + * standing example of what a silently moved count costs — and the pair count in the + * file header is the second, re-derived at objectui#7352 contract review. * * ## Why mirroring is the wrong remedy for these (the objectui#6152 ruling) * @@ -1385,7 +1417,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne /** * Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the - * 120 pairs with no entry (160 − 40). + * 115 pairs with no entry (155 − 40). * * Routed through `ReconcileAgainstLedger` rather than spelling the conditional * inline. That is a semantics-preserving refactor and nothing else — the type is @@ -1433,15 +1465,16 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch /** * The SECOND half of the forward comparison: every pair's declared-but-unmirrored - * key set equals what the two ledgers TOGETHER record for it — `never` for the 144 - * pairs with no entry in either (160 − 16). Six of `RuntimeOnlyDeclared`'s seven - * pairs are a measured subset of `UnmirroredDeclared`'s 15, so objectui#6152's + * key set equals what the two ledgers TOGETHER record for it — `never` for the 141 + * pairs with no entry in either (155 − 14). Six of `RuntimeOnlyDeclared`'s seven + * pairs are a measured subset of `UnmirroredDeclared`'s 13, so objectui#6152's * reclassification left the clean population unchanged; objectui#6150 then added - * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is 16 - * pairs and not 15. (objectui#6576 took the union to 18; objectui#7129 brought it - * back to 17 by retiring `DetailViewSectionSchema`'s only ledgered key, and - * objectui#7623 to 16 by retiring `DashboardComponentSchema`'s — each leaving its - * pair with no entry in either half. ⚠️ That pair still carries a `KnownDrift` + * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is one + * pair larger than `UnmirroredDeclared` itself. (objectui#6576 took the union to 18; + * objectui#7129 brought it back to 17 by retiring `DetailViewSectionSchema`'s only + * ledgered key, objectui#7623 to 16 by retiring `DashboardComponentSchema`'s, and + * objectui#7352 to 14 by MIRRORING both `drillDown` entries — each leaving its + * pair with no entry in either half. ⚠️ Two of those pairs still carry a `KnownDrift` * entry: "no entry in either" is about the two UNMIRRORED ledgers.) * * ⚠️ **The discriminating signal is the PER-PAIR set, not this file's exit code.** @@ -1492,7 +1525,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { }[MirrorKey]; /** - * Non-vacuity for all 160 entries at once. + * Non-vacuity for all 155 entries at once. * * `NarrowerThanDeclared` is `never` — green — for an entry whose mirror exposes no * `.shape`, and also for one whose key union has degenerated to bare `string` (the diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 651c8a901..271444921 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2865,6 +2865,12 @@ export interface ObjectDataTableSchema extends BaseSchema { /** * Union type of all ObjectQL component schemas + * + * `ObjectGallerySchema` and `ObjectDataTableSchema` joined in objectui#7363. + * PR #7355 (objectui#6576) minted both beside the other members and left this + * union alone, so `Extract< ObjectQLComponentSchema, { type: 'object-gallery' } >` + * was `never` and — through the zod twin in `zod/objectql.zod.ts`, which carries + * the same twelve members — `AnyComponentSchema` had no arm for either node. */ export type ObjectQLComponentSchema = | ObjectGridSchema @@ -2876,4 +2882,6 @@ export type ObjectQLComponentSchema = | ObjectCalendarSchema | ObjectKanbanSchema | ObjectChartSchema + | ObjectGallerySchema + | ObjectDataTableSchema | ListViewSchema; diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 1178da417..10ba8741b 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -453,6 +453,71 @@ function foldChartXAxisAlias>(input: T): T { return (rest.xAxisKey === undefined ? { ...rest, xAxisKey: alias } : rest) as T; } +/** + * Drill-down configuration — the zod mirror of `DrillDownConfig` + * (`../data-display.ts`), key for key (objectui#7352). + * + * Shared by the declarations that carry `drillDown`: `ChartSchema` below and + * `ObjectDataTableSchema` (`objectql.zod.ts`) reference it. `PivotTableSchema` + * declares the key too but has no mirror of its own, so it sits in no ledger; + * this is the home that key uses whenever the pivot pair is mirrored. Until + * this mirror existed neither declaring mirror had heard of the key, so under + * `BaseSchema`'s `.passthrough()` a `drillDown: { enabled: 'yes' }` parsed green + * and reached a widget that reads `enabled` as truthy — `declared !== enforced`, + * ledgered in `zod-mirror-parity.test.ts` (`UnmirroredDeclared`) by + * objectui#6058 for `ChartSchema` and by objectui#6576 for `ObjectDataTableSchema`. + * + * ⚠️ NOT `@objectstack/spec/ui`'s `ChartDrillDownSchema`, deliberately. That + * object is the CHART-ONLY subset (`enabled` / `filter` / `title` / `target` / + * `columns` / `maxRows`), strict, and refuses `mode` and `report` BY NAME with + * guidance — both are live keys on this wider type: `mode` picks drill-through + * vs drill-to-record on tables / pivots / metrics (`DashboardRenderer` writes + * `{ enabled: true, mode: 'record' }` for every object-backed table widget), and + * `report` drills a metric into an analytical report. Referencing the spec's + * object would make the published validator refuse what the published + * TypeScript declares. No spec export is named `DrillDownConfig` or + * `DrillDownConfigSchema`, so `check:spec-symbols` has nothing to match; the + * pair is registered against the LOCAL declaration, the `ObjectMapConfigSchema` + * precedent. Whether `ChartSchema.drillDown` should one day narrow to the + * spec's chart subset is a separate ruling — the declaration says + * `DrillDownConfig`, and this mirror says the same. + * + * `report` keeps the declaration's structural union: an inline report shape + * (`name` + `objectName` + `columns`, `type` optional, every other report key + * riding through on the index signature — `.catchall(z.unknown())` is what + * `[k: string]: unknown` spells) OR a named reference `{ name }`. Arm order + * matters to `z.union`: the inline arm is tried first, so a value satisfying it + * keeps its extra keys; only a value that fails it falls through to the + * reference arm. + */ +export const DrillDownConfigSchema = z.object({ + enabled: z.boolean().optional().describe('Master switch — true, or any other key present, turns the drill on'), + mode: z.enum(['filter', 'record']).optional().describe( + "'filter' (the aggregate default) drills through to a filtered list; 'record' (the table / list default) opens the clicked record itself", + ), + target: z.enum(['drawer', 'dialog', 'navigate']).optional().describe( + "Where the drill lands: 'drawer' (default), 'dialog', or 'navigate' to the object's full list page (falls back to 'drawer' without host drill navigation)", + ), + filter: z.record(z.string(), z.unknown()).optional().describe('Filter applied to the drilled list; values support ${event.*} interpolation'), + title: z.string().optional().describe('Drawer / dialog title; supports ${event.*} interpolation'), + report: z + .union([ + z + .object({ + name: z.string(), + objectName: z.string(), + type: z.enum(['tabular', 'summary', 'matrix', 'joined']).optional(), + columns: z.array(z.unknown()), + }) + .catchall(z.unknown()), + z.object({ name: z.string() }), + ]) + .optional() + .describe('Drill into an analytical report instead of the record list: an inline SpecReport shape, or a named report reference'), + columns: z.array(z.string()).optional().describe('Column whitelist for the inline drill list'), + maxRows: z.number().optional().describe('Hard cap on rows fetched'), +}); + /** * Chart Schema - Chart/graph component * @@ -506,6 +571,7 @@ export const ChartSchema = BaseSchema.extend({ showGrid: z.boolean().optional().describe('Show grid lines'), animate: z.boolean().optional().describe('Enable animations'), config: z.record(z.string(), z.any()).optional().describe('Additional chart configuration'), + drillDown: DrillDownConfigSchema.optional().describe('Drill-down: clicking a chart segment opens a filtered list view (drawer / dialog)'), }).overwrite(foldChartXAxisAlias); /** diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index cba8d1aa1..94054257c 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -143,6 +143,7 @@ export { TreeViewSchema, ChartTypeSchema, ChartDataSeriesSchema, + DrillDownConfigSchema, ChartSchema, TimelineEventSchema, TimelineSchema, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 507b8d66e..70fec93c6 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -37,6 +37,7 @@ import { } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; import { handlerKeyRefusal } from './tombstone.zod.js'; +import { DrillDownConfigSchema } from './data-display.zod.js'; /** * HTTP Method Schema — `@objectstack/spec/ui` schema re-exported by reference @@ -935,18 +936,21 @@ export const ObjectGallerySchema = BaseSchema.extend({ /** * ObjectDataTable Schema (objectui#6576 / objectui#6914) * - * Mirrors the `ObjectDataTableSchema` interface in `objectql.ts`. Two keys - * follow the parity ledger's discipline rather than the literal shape: + * Mirrors the `ObjectDataTableSchema` interface in `objectql.ts`. One key + * follows the parity ledger's discipline rather than the literal shape, and + * one used to: * * - `onRowClick` is a RUNTIME SLOT — a host-supplied function the widget * forwards into `data-table` — so the mirror refuses it BY NAME * (`handlerKeyRefusal`, the objectui#6124 shape) while the TS twin stays * callable; `KnownDrift` in `zod-mirror-parity.test.ts` records the * divergence. - * - `drillDown` is declared on the TS face and deliberately NOT mirrored: - * `DrillDownConfig` has no zod mirror in this package (`ChartSchema.drillDown` - * is in the same state), and minting one is a new export outside the - * objectui#6576 ruling. `UnmirroredDeclared` records it. + * - `drillDown` is mirrored through `DrillDownConfigSchema` + * (`data-display.zod.ts`, objectui#7352). objectui#6576 declared the key + * and left it unmirrored — minting the mirror was a new export outside + * that ruling — so `UnmirroredDeclared` carried it, as it had carried + * `ChartSchema.drillDown` since objectui#6058; both entries left the + * ledger with that mirror. */ export const ObjectDataTableSchema = BaseSchema.extend({ type: z.literal('object-data-table'), @@ -958,11 +962,26 @@ export const ObjectDataTableSchema = BaseSchema.extend({ columns: z.array(z.any()).optional().describe('Column definitions (names or column objects)'), searchable: z.boolean().optional().describe('Forwarded to the rendered data-table'), pagination: z.boolean().optional().describe('Forwarded to the rendered data-table'), + drillDown: DrillDownConfigSchema.optional().describe( + 'Drill-to-record: clicking a row opens that record in a detail drawer (DashboardRenderer defaults object-backed table widgets to { enabled: true, mode: record })', + ), onRowClick: handlerKeyRefusal('onRowClick', 'runtime-slot', 'Row click handler (overrides drill-to-record)'), }); /** * ObjectQL Component Schema Union + * + * Same twelve members as the TS union in `../objectql.ts`, in the same order. + * `ObjectGallerySchema` and `ObjectDataTableSchema` joined in objectui#7363: + * PR #7355 (objectui#6576) minted both mirrors and deliberately did not extend + * this union, so `AnyComponentSchema` — and `validateSchema` / + * `safeValidateSchema` / `objectui validate` with it — had NO arm for an + * `object-gallery` or `object-data-table` node. Such a document was refused as + * matching no arm, exactly as before the mirrors existed, and a wrong-typed + * declared key on it (`searchable: 'yes'`) could never be diagnosed by name. + * Both nodes render (`plugin-list` registers `object-gallery`, `plugin-dashboard` + * registers `object-data-table`); this is the validating face catching up with + * the rendering one. The behaviour pin is `__tests__/objectql-union-arms-7363.test.ts`. */ export const ObjectQLComponentSchema = z.union([ ObjectGridSchema, @@ -974,5 +993,7 @@ export const ObjectQLComponentSchema = z.union([ ObjectCalendarSchema, ObjectKanbanSchema, ObjectChartSchema, + ObjectGallerySchema, + ObjectDataTableSchema, ListViewSchema, ]);