From 29c04792e76113c290d3c1b6ec647e03a3c30fd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:59:26 +0000 Subject: [PATCH 1/2] fix(types): refuse a bare exportOptions array on the object-grid zod mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectGridSchema` declared no `exportOptions` member, and `BaseSchema` is `.passthrough()`, so a bare format array validated green and came back verbatim — then lost silently to the `['csv', 'json']` default, because `ObjectGrid` reads `schema.exportOptions?.formats` and nothing else and the warning path reads `.formats` too. The retired `'pdf'` value and a sixth key rode through the same hole. Declare the member as the spec's OBJECT arm, peeled out of `ListViewSchema.shape.exportOptions` and bound by reference, so the bare array is refused by name with a message pointing at the shape the renderer reads, while `'pdf'` and a sixth key keep the spec's own messages. Not the spec union itself: its first arm lifts a bare array to `{ formats }`, which is the opposite of the refusal ruled here — the sibling `list-view` mirror keeps that binding and keeps lifting. The key leaves `UnmirroredDeclared` (87 -> 86 keys) and the spec reference re-derives the pair into the split's SPEC-DERIVED half (2/3 -> 3/17 spec-derived, 12/84 -> 11/69 local); every pinned figure moved with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- ...-grid-export-options-bare-array-refusal.md | 60 +++++ ...t-grid-export-options-refusal-7762.test.ts | 215 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 56 ++++- packages/types/src/zod/objectql.zod.ts | 88 +++++++ 4 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 .changeset/7762-object-grid-export-options-bare-array-refusal.md create mode 100644 packages/types/src/__tests__/object-grid-export-options-refusal-7762.test.ts diff --git a/.changeset/7762-object-grid-export-options-bare-array-refusal.md b/.changeset/7762-object-grid-export-options-bare-array-refusal.md new file mode 100644 index 0000000000..0a6808f663 --- /dev/null +++ b/.changeset/7762-object-grid-export-options-bare-array-refusal.md @@ -0,0 +1,60 @@ +--- +'@object-ui/types': minor +--- + +**Breaking for authored metadata:** `ObjectGridSchema` in `@object-ui/types/zod` +now DECLARES `exportOptions`, as `@objectstack/spec`'s own OBJECT arm bound by +reference, and REFUSES the bare format array by name (objectui#7762). An +`object-grid` document that authors `exportOptions: ['csv', 'xlsx']` no longer +validates through this package's mirror; it gets one `invalid_type` issue at +`['exportOptions']` whose message names the shape the renderer reads and tells +the author to write `{ "formats": ["csv", "xlsx"] }`. The retired `'pdf'` format +and a sixth key on the object form (`{ formats: ['csv'], compression: 'gzip' }`) +are refused on this node too, carrying the spec's own messages — the +`os migrate meta --from 16` prescription for `'pdf'`, and zod's +`unrecognized_keys` naming the extra key. + +**What was measured, on this branch's base.** The mirror declared NO +`exportOptions` member at all, and `BaseSchema` is `.passthrough()`, so +`ObjectGridSchema.safeParse({ type: 'object-grid', objectName: 'accounts', +exportOptions: ['csv', 'xlsx'] })` returned `success: true` with the array back +VERBATIM — as did `{ formats: ['csv', 'pdf'], compression: 'gzip' }`. Nothing on +the render path parses, and `ObjectGrid.tsx` reads `schema.exportOptions?.formats` +and only that, so the authored array then lost SILENTLY to the `['csv', 'json']` +default: the `useEffect` that warns about dropped formats reads `.formats` too +and returns early when it is absent, while `!!schema.exportOptions` kept the +export button on screen. An author declared `['csv', 'xlsx']` and got csv/json +with no error, no warning and no console line. The two authoring faces disagreed +in the direction opposite to objectui#6956's: the TypeScript interface already +declared the object form only, so TS refused what zod admitted. + +**Why the object arm alone, and not the spec union.** The sibling `list-view` +mirror binds `SpecListViewSchema.shape.exportOptions` whole, and is right to: +that reference is a two-arm union whose first arm LIFTS a bare array to +`{ formats }` at parse, and `list-view` reads both spellings. Binding it here +would make this mirror accept and lift — the opposite of the refusal this card +rules. So the object arm is peeled out of that union and the lifting arm left +behind. Every member schema is the spec's own object (identity-pinned), so a +spec-side change moves this member with it and no third copy of the five keys +exists to drift. + +**Who is NOT affected.** A node authoring `{ formats: ['csv', 'json'] }` or any +combination of the five spec keys (`formats` / `maxRecords` / `includeHeaders` / +`fileNamePrefix` / `streaming`) is untouched and its values come back verbatim; +a node with no `exportOptions` is unchanged; `list-view` documents are entirely +unchanged, including the bare-array spelling that still lifts there. Nothing +that renders today stops rendering: the refused spelling was already a silent +no-op at runtime, so the narrowing costs no working artefact. `packages/plugin-grid` +is untouched — the renderer's read was ruled correct and this is the declaration +catching up to it. + +**Migration:** write the object form — `exportOptions: { "formats": ["csv", +"xlsx"] }` instead of `exportOptions: ["csv", "xlsx"]`. Delete `'pdf'` (the +surviving formats are `'csv'`, `'xlsx'` and `'json'`) and any key outside the +five above; `os migrate meta --from 16` lists the mechanical edits. + +Graded `minor`, not `patch`: this narrows the accepted input set, which is +breaking for any author who wrote the tolerated spelling. It is not `major` per +this repo's fixed-group convention (objectui's own breaking changes ship as +`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略, +mechanically enforced by `scripts/check-changeset-no-major.mjs`). diff --git a/packages/types/src/__tests__/object-grid-export-options-refusal-7762.test.ts b/packages/types/src/__tests__/object-grid-export-options-refusal-7762.test.ts new file mode 100644 index 0000000000..fc3fcceead --- /dev/null +++ b/packages/types/src/__tests__/object-grid-export-options-refusal-7762.test.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ObjectGridSchema.exportOptions` is the spec's OBJECT arm, by reference, and the bare + * format array is refused BY NAME (objectui#7762, `domain:spec` routing ruling). + * + * ## The defect this pins closed + * + * `ObjectGrid.tsx` reads `schema.exportOptions?.formats` and nothing else, and the + * `useEffect` that warns about dropped formats reads `.formats` too and returns early when + * it is absent. A bare format array has no `.formats`, so `['csv', 'json']` — the default — + * won, with no error, no warning and no console line, while `!!schema.exportOptions` kept + * the export button on screen. The mirror declared no `exportOptions` member at all, and + * `BaseSchema` is `.passthrough()`, so the array validated GREEN and came back verbatim. + * Same for a retired `'pdf'` value and a sixth key: this node admitted both, where the + * `list-view` mirror (objectui#6956) refuses them. + * + * The ruling: the contract REFUSES the bare array on this node, by name, with a message + * pointing at the object shape the renderer actually reads. ⛔ NOT a fold at the renderer's + * read site — `packages/plugin-grid/**` is untouched and its read is held to be correct. + * + * ## Why the object arm is PEELED rather than bound whole, and why not restated + * + * `SpecListViewSchema.shape.exportOptions` — what the sibling `ListViewSchema` mirror binds + * — is a TWO-ARM union whose first arm LIFTS a bare array to `{ formats }` at parse. + * Binding that reference here would make this mirror ACCEPT AND LIFT: the opposite of the + * ruled refusal. So the object arm is peeled out and the lifting arm left behind. It is + * peeled rather than restated because a local copy of the five keys is a third copy of one + * contract, and the copy is what drifts — the lesson `objectql.ts`'s `ListViewExportOptions` + * docblock already records for the TypeScript face. + * + * Identity is therefore the load-bearing assertion below: a restated copy passes every + * verdict test on the day it is written and drifts afterwards; `toBe` cannot. + * + * ## Non-vacuity + * + * Membership is read off `.shape`, not off acceptance: the base is `.passthrough()`, so + * acceptance cannot distinguish "declared" from "admitted unexamined" and was green before + * the fix too. Every refusal assertion carries the live control that was green BEFORE this + * card (the sibling `list-view` mirror still lifting the same array), so a mirror that had + * simply stopped parsing anything could not pass this file. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { ObjectGridSchema, ListViewSchema as MirrorListViewSchema } from '../zod/objectql.zod'; +import type { ObjectGridSchema as TsObjectGridSchema, ListViewExportOptions } from '../objectql'; + +/* ── Type-level helpers (invariant equality, house form) ─────────────────── */ + +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; + +/** + * The mirror's AUTHORING face (`z.input`) for this key is the interface's type — object + * only, no array arm. This is the half `zod-mirror-parity.test.ts` reconciles as a pair; + * pinned here too so a widening that keeps the ledger green is still visible on the card. + */ +export type _FaceIsTheObjectFormOnly = Expect< + Equal< NonNullable< z.input< typeof ObjectGridSchema >['exportOptions'] >, ListViewExportOptions > +>; + +/** And the interface face is unchanged by this card. */ +export type _InterfaceFaceUnchanged = Expect< + Equal< NonNullable< TsObjectGridSchema['exportOptions'] >, ListViewExportOptions > +>; + +/* ── Reaching the spec's object arm, the way the sibling parity test does ── */ + +type ZodLike = { + unwrap?: () => ZodLike; + options?: ZodLike[]; + shape?: Record; +}; + +const specExportOptions = (SpecListViewSchema as unknown as { shape: Record }) + .shape.exportOptions; + +const specUnion = ((): ZodLike => { + let cur = specExportOptions; + for (let i = 0; i < 5 && cur && !cur.options && typeof cur.unwrap === 'function'; i++) cur = cur.unwrap(); + return cur; +})(); + +const specObjectArm = specUnion.options?.find((o) => o.shape); + +/** A minimal legal `object-grid` node: the two required keys and nothing else. */ +const NODE = { type: 'object-grid' as const, objectName: 'accounts' }; + +const parse = (exportOptions: unknown) => ObjectGridSchema.safeParse({ ...NODE, exportOptions }); + +describe('ObjectGridSchema.exportOptions — the spec object arm, by reference (objectui#7762)', () => { + it('finds the spec shape it is about to compare against (non-vacuity floor)', () => { + // Every identity assertion below reads through `specObjectArm`. A spec refactor that + // moved the shape would otherwise turn each of them into a comparison against + // `undefined`, and `toBe(undefined) === toBe(undefined)` would pass. + expect(specExportOptions).toBeDefined(); + expect(specUnion.options).toHaveLength(2); + expect(specObjectArm).toBeDefined(); + expect(Object.keys(specObjectArm?.shape ?? {}).sort()) + .toEqual(['fileNamePrefix', 'formats', 'includeHeaders', 'maxRecords', 'streaming']); + }); + + it('is a member of the mirror shape (membership cannot be read off acceptance under passthrough)', () => { + expect('exportOptions' in ObjectGridSchema.shape).toBe(true); + // Live positive control and a negative one, so the `true` above is a reading rather + // than an artefact of how `in` behaves on this object. + expect('bulkActions' in ObjectGridSchema.shape).toBe(true); + expect('zzNoSuchKey' in ObjectGridSchema.shape).toBe(false); + }); + + it('takes every member schema FROM the spec arm — identity, not a restatement', () => { + const mirrorArm = (ObjectGridSchema.shape.exportOptions as unknown as { unwrap: () => { shape: Record } }) + .unwrap(); + const specShape = specObjectArm?.shape ?? {}; + expect(Object.keys(mirrorArm.shape).sort()).toEqual(Object.keys(specShape).sort()); + for (const key of Object.keys(specShape)) { + // `toBe`, not `toEqual`: a verbatim hand copy would satisfy `toEqual` on the day it + // was written. Identity is what makes a spec-side change move this member too. + expect(mirrorArm.shape[key]).toBe(specShape[key]); + } + }); + + it('⛔ is NOT the spec union itself — that reference lifts, and lifting is the defect', () => { + // The single most likely wrong fix, pinned so it cannot land silently. The sibling + // `list-view` mirror DOES bind the union and MUST keep lifting: that is the live + // control proving this file measures a difference between the two nodes rather than a + // validator that stopped working. + expect(ObjectGridSchema.shape.exportOptions).not.toBe(specExportOptions); + const lifted = MirrorListViewSchema.safeParse({ + type: 'list-view', objectName: 'accounts', exportOptions: ['csv', 'xlsx'], + }); + expect(lifted.success).toBe(true); + expect(lifted.data?.exportOptions).toEqual({ formats: ['csv', 'xlsx'] }); + }); +}); + +describe('the four authored readings (objectui#7762)', () => { + it('REFUSES a bare format array by name — one issue, invalid_type, at the key', () => { + const refused = parse(['csv', 'xlsx']); + expect(refused.success).toBe(false); + const issues = refused.error?.issues ?? []; + // Exactly one: a refusal that also reported the array's ELEMENTS would be reporting on + // a shape this node does not model, and would bury the actionable line. + expect(issues).toHaveLength(1); + expect(issues[0]?.code).toBe('invalid_type'); + expect(issues[0]?.path).toEqual(['exportOptions']); + // The message is the point of the ruling, not an incidental string: it must name the + // shape the renderer reads, or an author meets zod's own "expected object, received + // array" and is told nothing about what to write. + expect(issues[0]?.message).toMatch(/formats/); + expect(issues[0]?.message).toMatch(/list-view/); + expect(issues[0]?.message).toMatch(/object-grid/); + }); + + it('ACCEPTS the five-key object form and returns it VERBATIM', () => { + // A non-strict object would accept and STRIP — green on `success` while silently + // dropping the opt-out the renderer honours. Assert the values come back. + const authored = { + formats: ['csv', 'xlsx'], maxRecords: 10, includeHeaders: true, + fileNamePrefix: 'accounts', streaming: false, + }; + const parsed = parse(authored); + expect(parsed.success).toBe(true); + expect(parsed.data?.exportOptions).toEqual(authored); + }); + + it("REFUSES a retired 'pdf' format, carrying the SPEC's own migration prescription", () => { + const refused = parse({ formats: ['csv', 'pdf'] }); + expect(refused.success).toBe(false); + const issues = refused.error?.issues ?? []; + expect(issues.some((i) => i.path[0] === 'exportOptions')).toBe(true); + const messages = issues.map((i) => i.message).join('\n'); + // The prescription, not the citation: @objectstack/spec 17.3.0 stripped the issue + // numbers and kept the actionable half, the same move `export-options-spec-parity.test.ts` + // already followed. Asserting only `success === false` would stay green if this member + // were replaced by a local restatement that had never heard of the retirement. + expect(messages).toMatch(/'csv', 'xlsx' and 'json'/); + expect(messages).toMatch(/os migrate meta/); + }); + + it('REFUSES a sixth key — strict, as upstream, and no silent strip', () => { + const refused = parse({ formats: ['csv'], compression: 'gzip' }); + expect(refused.success).toBe(false); + const issues = refused.error?.issues ?? []; + expect(issues.some((i) => i.code === 'unrecognized_keys' && i.path[0] === 'exportOptions')).toBe(true); + // The key is named, so the author can act without reading the schema. + expect(issues.map((i) => i.message).join('\n')).toMatch(/compression/); + }); +}); + +describe('the narrowing does not reach anything else (objectui#7762)', () => { + it('a node with no exportOptions at all is unchanged', () => { + expect(ObjectGridSchema.safeParse(NODE).success).toBe(true); + }); + + it('control: neighbouring members keep their accept sets', () => { + // `bulkActions` and `pagination` sit in the same `.extend()` block and are untouched. + expect(ObjectGridSchema.shape.bulkActions.safeParse(['delete']).success).toBe(true); + expect(ObjectGridSchema.shape.bulkActions.safeParse('delete').success).toBe(false); + expect(ObjectGridSchema.shape.pagination.safeParse({ pageSize: 25 }).success).toBe(true); + }); + + it('the guidance string feeds BOTH author-facing channels, so they cannot drift', () => { + // The `./tombstone.zod.ts` house discipline: one string, the `.describe()` metadata + // generated docs publish AND the parse-time issue message. Read them off the live + // schema and compare, rather than restating either. + const described = ObjectGridSchema.shape.exportOptions.description; + const refused = parse(['csv']); + expect(described).toBeDefined(); + expect(refused.error?.issues[0]?.message).toBe(described); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 325ea74da0..41c46d8074 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -150,7 +150,9 @@ * 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`. - * - **14 entries** in `UnmirroredDeclared`, **87 keys** across them — 14 / 96 until + * - **14 entries** in `UnmirroredDeclared`, **86 keys** across them — 14 / 87 until + * objectui#7762 MIRRORED `ObjectGridSchema.exportOptions` (the entry kept its other + * fourteen keys, so the entry count held); 14 / 96 until * objectui#7779 closed nine of `ObjectViewSchema`'s ten keys (maintainer ruling * B, 2026-09-06): eight MIRRORED — `navigation`, `searchableFields`, * `filterableFields` by reference to the spec's `ListViewSchema` slots, @@ -1297,7 +1299,7 @@ interface KnownDrift { * * objectui#6058 seeded this ledger at **121 keys**, and — on ONE line, because the * pin below reads this sentence off disk — - * **87 keys** is what this ledger records today. + * **86 keys** is what this ledger records today. * The movements between the two 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; @@ -1401,7 +1403,12 @@ interface KnownDrift { * explicit that forcing the 121 per-key decisions now would be wrong. Two splits * are recorded here so whoever works them off does not re-derive them: * - * - **SPEC-DERIVED (2 entries, 3 keys)** — `DashboardWidgetSchema`, and since + * - **SPEC-DERIVED (3 entries, 17 keys)** — it was 2 / 3 until objectui#7762 MIRRORED + * `ObjectGridSchema.exportOptions` as the spec's object arm BY REFERENCE, which + * re-derived that entry (its fourteen remaining keys) into this half: the #7779 + * membership mechanism a second time, and the first entry to arrive here by a single + * mirrored member. ⚠️ Its keys' remedy did NOT change with the half — they are still + * plain local omissions; the reading is on the entry. Then `DashboardWidgetSchema`, and since * objectui#7779 `ObjectViewSchema` again, by MEMBERSHIP this time rather than by * the old false positive: that card made the mirror reference * `SpecListViewSchema.shape.navigation` / `.searchableFields` / @@ -1454,7 +1461,11 @@ 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, 84 keys)** — plain omissions from a hand-written mirror. + * - **LOCAL (11 entries, 69 keys)** — plain omissions from a hand-written mirror. + * It was 12 / 84 until objectui#7762 MIRRORED `ObjectGridSchema.exportOptions`: one key + * REPAIRED and the entry's other fourteen carried out of this half with it, because the + * spec reference that repair introduced re-derives the pair into the SPEC-DERIVED half + * above — the same two-effects-in-one-move shape objectui#7779 had on `ObjectViewSchema`. * It was 13 / 94 until objectui#7779 MIRRORED eight of `ObjectViewSchema`'s ten * keys and RETIRED a ninth, and the spec reference that mirroring introduced * re-derived the entry (down to `listViews`) into the SPEC-DERIVED half — 12 / 84 @@ -1485,10 +1496,14 @@ interface KnownDrift { * same entry from ten keys to one (96 → 87 keys; the entry count held) and moved it * back to the SPEC-DERIVED half by membership — the ledger's first shrink by * MIRRORING BY REFERENCE across most of an entry, and its second RETIREMENT on the - * LOCAL half (`viewTabBar`). The seeded pair is no longer + * LOCAL half (`viewTabBar`). objectui#7762 then did both halves of that move again on + * `ObjectGridSchema` with ONE key: mirroring `exportOptions` as the spec's object arm by + * reference shrank the ledger 87 → 86 and re-derived the entry (fourteen keys) from the + * LOCAL half into the SPEC-DERIVED one, so the entry count held while BOTH split figures + * moved. The seeded pair is no longer * among them, and the ledger now totals — on ONE line, because the pin below reads * this sentence off disk — - * **14 entries / 87 keys** — 2 / 3 spec-derived, 12 / 84 local. + * **14 entries / 86 keys** — 3 / 17 spec-derived, 11 / 69 local. * * ⛔ The four split figures above and this totals line are PINNED: 'objectui#7279' * at the bottom of this file derives every one of them from the `UnmirroredDeclared` @@ -1614,14 +1629,29 @@ interface UnmirroredDeclared { | 'sections' | 'showStepIndicator' | 'splitDirection' | 'splitResizable' | 'splitSize' | 'subforms' | 'submitHandler' | 'tabPosition'; /** - * LOCAL. It was 17: `onNavigate` is in `RuntimeOnlyDeclared` below (objectui#6152), - * and `title` was MIRRORED by objectui#6639 (census-directed ruling 2026-08-29, - * declare branch) — the ledger's first shrink by REPAIR rather than + * SPEC-DERIVED by MEMBERSHIP since objectui#7762, LOCAL before it: that card mirrored + * `exportOptions` as the spec's OBJECT ARM by reference, which puts a `Spec…` symbol in + * this mirror's initializer, so `SPEC_DERIVED_PAIRS` re-derives the pair — the same + * mechanism that moved `ObjectViewSchema` at objectui#7779, and the second pair to reach + * this half by a single mirrored member. + * + * ⚠️ Read the half as MEMBERSHIP, not as a remedy: the fourteen keys below are still + * plain hand-written omissions on a `BaseSchema.extend({…})` mirror — the ordinary local + * route (objectui#6152's worklist), NOT objectui#2231's unification question, which is + * what the SPEC-DERIVED half means for a mirror that IS the spec schema by reference. + * This is the per-key reading objectui#7279 recorded on `ObjectViewSchema` for the same + * reason, kept here so whoever works these off does not re-derive it. + * + * It was 15 until objectui#7762 MIRRORED `exportOptions` — the ledger's shrink by REPAIR + * on the route objectui#6639 opened, and the first one that also moved its entry between + * the split's halves. It was 17: `onNavigate` is in `RuntimeOnlyDeclared` below + * (objectui#6152), and `title` was MIRRORED by objectui#6639 (census-directed ruling + * 2026-08-29, declare branch) — the ledger's first shrink by REPAIR rather than * reclassification. */ 'objectql.zod.ts#ObjectGridSchema': | 'aggregations' | 'bulkActionDefs' | 'bulkSpecActions' | 'conditionalFormatting' - | 'emptyState' | 'exportOptions' | 'grouping' | 'navigation' | 'operations' + | 'emptyState' | 'grouping' | 'navigation' | 'operations' | 'reorderableColumns' | 'resizableColumns' | 'rowColor' | 'rowHeight' | 'rowSpecActions' | 'singleClickEdit'; /** @@ -1692,7 +1722,7 @@ interface UnmirroredDeclared { * * `UnmirroredDeclared` above was seeded at **121 keys** by objectui#6058, and — on * ONE line, because the pin below reads this sentence off disk — - * `UnmirroredDeclared` records **87 keys** today. + * `UnmirroredDeclared` records **86 keys** today. * These 23 moved here whole. Keys have since left that ledger by MIRRORING and by * RETIREMENT, but the move recorded HERE is neither and repaired nothing. ⛔ Nothing * was mirrored by it, no declaration was removed, no defect was repaired and nothing was @@ -2696,6 +2726,10 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ 'layout.zod.ts#PageNodeSchema', 'objectql.zod.ts#ObjectGallerySchema', 'objectql.zod.ts#ObjectGanttSchema', + // objectui#7762: `exportOptions` is the spec's OBJECT ARM by reference — peeled out of + // `SpecListViewSchema.shape.exportOptions` (the two-arm union) so the bare-array arm, + // which LIFTS, is left behind and refused by name on this node instead. + 'objectql.zod.ts#ObjectGridSchema', 'objectql.zod.ts#ObjectMapSchema', // objectui#7779: BACK, by a real code reference this time — `navigation`, // `searchableFields` and `filterableFields` are `SpecListViewSchema.shape.*` diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 4ddc342c65..7e1929d740 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -106,6 +106,80 @@ export const SortConfigSchema = z.object({ order: z.enum(['asc', 'desc']).describe('Sort order'), }); +/** + * The spec's OBJECT arm of `exportOptions`, as a SHAPE, reached without restating + * its keys (objectui#7762). + * + * `ListViewExportOptionsSchema` is internal to the spec bundle and NOT a public + * export (measured, not assumed, by `../__tests__/export-options-spec-parity.test.ts`). + * The exported `ListViewSchema.shape.exportOptions` is the whole contract, but it is a + * TWO-ARM union: the legacy bare format array — which LIFTS to `{ formats }` at parse — + * and the strict five-key object. `ListViewSchema` below binds that union by reference + * and is right to: `list-view` reads both spellings. `object-grid` does not, so this + * peels the object arm out and leaves the lifting arm behind. + * + * Peeled rather than restated on purpose: a local copy of the five keys is a third + * copy of one contract, and the copy is what drifts (the lesson `objectql.ts`'s + * `ListViewExportOptions` docblock already records for the TypeScript face). Every + * member here is the spec's own schema object, so `'pdf'` and a sixth key stay refused + * with the spec's own messages and a spec-side change moves this member with it. + * + * The TYPE is derived by the same two steps at the TYPE level (`unwrap` then the arm with a + * `shape`), so the authoring face keeps the spec's per-member types — a `z.ZodRawShape` + * annotation here would erase them and collapse `z.input` of every member to `unknown`, + * which the mirror-parity drift ledger reports as `exportOptions` NARROWER than declared. + * + * THROWS at module load if the spec stops exposing an object arm. That is the intended + * failure: the alternative is a member that silently stops being the spec's, which is + * the class of defect this whole file exists to make visible. + */ +type SpecExportOptionsUnion = ReturnType< typeof SpecListViewSchema.shape.exportOptions.unwrap >; +/** The object arm, statically: the one union member exposing a `shape` (the other is the lift's pipe). */ +type SpecExportOptionsObjectArm = Extract< SpecExportOptionsUnion['options'][number], { shape: unknown } >; +type SpecExportOptionsShape = SpecExportOptionsObjectArm['shape']; + +const SPEC_EXPORT_OPTIONS_OBJECT_SHAPE: SpecExportOptionsShape = ((): SpecExportOptionsShape => { + type Peelable = { + unwrap?: () => Peelable; + options?: readonly Peelable[]; + shape?: SpecExportOptionsShape; + }; + let cur = SpecListViewSchema.shape.exportOptions as unknown as Peelable; + for (let i = 0; i < 5 && cur && !cur.options && typeof cur.unwrap === 'function'; i++) { + cur = cur.unwrap(); + } + const arm = cur.options?.find((o) => o.shape); + if (!arm?.shape) { + throw new Error( + '@object-ui/types: `ListViewSchema.shape.exportOptions` in @objectstack/spec no longer ' + + 'exposes an object arm. `ObjectGridSchema.exportOptions` is bound to that arm by ' + + 'reference (objectui#7762); re-derive it against the installed spec rather than ' + + 'restating the key set here.', + ); + } + return arm.shape; +})(); + +/** + * ONE string, BOTH author-facing channels — the `.describe()` metadata generated docs + * publish, and the parse-time issue message an author who writes the wrong shape reads. + * The house discipline of `./tombstone.zod.ts`: two channels that cannot drift apart + * because there is only one string. + * + * The refusal it carries is objectui#7762's ruling. `ObjectGrid.tsx` reads + * `schema.exportOptions?.formats` and nothing else, so a bare format array authored on + * an `object-grid` node used to validate green through `BaseSchema`'s `.passthrough()` + * and then lose SILENTLY to the `['csv', 'json']` default — no error, no warning, no + * console line, with the export button still shown. Refusing it by name is that silent + * no-op made loud; nothing that renders today stops rendering. + */ +const OBJECT_GRID_EXPORT_OPTIONS_GUIDANCE = + 'Export configuration for the grid toolbar export menu — the OBJECT form only: ' + + '`{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`. A bare format ' + + 'array is the `list-view` spelling and is NOT read here: `object-grid` reads ' + + '`exportOptions.formats`, so an array would be dropped in silence for the csv/json ' + + 'default. Write `{ "formats": ["csv", "xlsx"] }` instead.'; + /** * ObjectGrid Schema */ @@ -122,6 +196,20 @@ export const ObjectGridSchema = BaseSchema.extend({ selection: SelectionConfigSchema.optional().describe('Selection configuration'), pagination: PaginationConfigSchema.optional().describe('Pagination configuration'), bulkActions: z.array(z.string()).optional().describe('Bulk action identifiers (spec-canonical key; batchActions is the legacy alias)'), + // `exportOptions` — the spec's OBJECT arm, BY REFERENCE, with the bare array refused + // by name (objectui#7762). ⛔ NOT `SpecListViewSchema.shape.exportOptions` itself: + // that reference is the two-arm union whose first arm LIFTS a bare array to + // `{ formats }`, so binding it would make this mirror ACCEPT AND LIFT — the opposite + // of the named refusal ruled for this node. The strictness is the arm's own + // (`catchall: never`, measured), so a sixth key keeps zod's own `unrecognized_keys` + // message naming it, and a retired `'pdf'` keeps the spec's migration prescription: + // only the `invalid_type` message is local, and only it names the object form. + exportOptions: z + .strictObject(SPEC_EXPORT_OPTIONS_OBJECT_SHAPE, { + error: (issue) => (issue.code === 'invalid_type' ? OBJECT_GRID_EXPORT_OPTIONS_GUIDANCE : undefined), + }) + .optional() + .describe(OBJECT_GRID_EXPORT_OPTIONS_GUIDANCE), // Legacy fields fields: z.array(z.string()).optional(), From 1ee7f72d3c8ecf6273b7258fdd6955ae2bc3227b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:31:01 +0000 Subject: [PATCH 2/2] perf(types): trim the shipped exportOptions strings to fit the framework budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console's `framework` chunk is `packages/(core|react|types)`, and the `exportOptions` refusal ships two string constants into it. Measured against a clean `origin/main` build: `framework` was 70,651 gzip bytes with a 71,000 byte ceiling (349 bytes of headroom), and this branch took it to 71,042 — over by 42. Two reductions, each pure redundancy removal with no fact lost: * the module-load `throw` message, 272 -> 144 raw bytes, worth 35 gzip bytes. It still names the moved spec symbol, the member that depends on it and the card; the rationale it used to restate is in the docblock above it, which the production minifier strips while the string ships. * two redundant phrases in the shared guidance string, 378 -> 363 raw bytes, worth 8 gzip bytes ("grid toolbar export menu" -> "grid toolbar menu", after a sentence that already says "Export configuration"; "would be dropped in silence" -> "is silently dropped"). `framework` now measures 70,999 against the 71,000 ceiling. ⛔ No ceiling was raised and no baseline moved. Every accept-set reading and every assertion of the pin re-verified on this head: bare array still one invalid_type issue at ['exportOptions'] whose message still matches /formats/, /list-view/ and /object-grid/ and is still identical to the member's description; the five-key object still parses verbatim; 'pdf' and a sixth key still refused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- ...bject-grid-export-options-bare-array-refusal.md | 8 ++++++++ packages/types/src/zod/objectql.zod.ts | 14 ++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.changeset/7762-object-grid-export-options-bare-array-refusal.md b/.changeset/7762-object-grid-export-options-bare-array-refusal.md index 0a6808f663..110566d0de 100644 --- a/.changeset/7762-object-grid-export-options-bare-array-refusal.md +++ b/.changeset/7762-object-grid-export-options-bare-array-refusal.md @@ -48,6 +48,14 @@ no-op at runtime, so the narrowing costs no working artefact. `packages/plugin-g is untouched — the renderer's read was ruled correct and this is the declaration catching up to it. +**One key moves with it, named rather than left to be discovered:** +`ObjectViewSchema`'s `table` slot is `ObjectGridSchema.omit({ type, objectName }).partial()`, +so `table.exportOptions` narrows in the same move. Measured on both sides: the base +accepted `table: { exportOptions: ['csv', 'xlsx'] }` and the head refuses it, while the +object form parses on both. It costs no working artefact — the key has zero runtime +readers (no `table.exportOptions` read exists in `packages/**` or `apps/**`) and its +TypeScript face was already object-only, so the array was unrenderable there too. + **Migration:** write the object form — `exportOptions: { "formats": ["csv", "xlsx"] }` instead of `exportOptions: ["csv", "xlsx"]`. Delete `'pdf'` (the surviving formats are `'csv'`, `'xlsx'` and `'json'`) and any key outside the diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 7e1929d740..37f6f962c1 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -150,11 +150,13 @@ const SPEC_EXPORT_OPTIONS_OBJECT_SHAPE: SpecExportOptionsShape = ((): SpecExport } const arm = cur.options?.find((o) => o.shape); if (!arm?.shape) { + // Short on purpose: this string SHIPS (the console's `framework` chunk), while the + // docblock above it — which carries the rationale and the remedy — is stripped by the + // production minifier. It still names the moved spec symbol, the member that depends on + // it, and the card, which is what a diagnostic has to do. throw new Error( - '@object-ui/types: `ListViewSchema.shape.exportOptions` in @objectstack/spec no longer ' + - 'exposes an object arm. `ObjectGridSchema.exportOptions` is bound to that arm by ' + - 'reference (objectui#7762); re-derive it against the installed spec rather than ' + - 'restating the key set here.', + '@object-ui/types: no object arm on `ListViewSchema.shape.exportOptions`; ' + + '`ObjectGridSchema.exportOptions` binds it by reference (objectui#7762).', ); } return arm.shape; @@ -174,10 +176,10 @@ const SPEC_EXPORT_OPTIONS_OBJECT_SHAPE: SpecExportOptionsShape = ((): SpecExport * no-op made loud; nothing that renders today stops rendering. */ const OBJECT_GRID_EXPORT_OPTIONS_GUIDANCE = - 'Export configuration for the grid toolbar export menu — the OBJECT form only: ' + + 'Export configuration for the grid toolbar menu — the OBJECT form only: ' + '`{ formats, maxRecords, includeHeaders, fileNamePrefix, streaming }`. A bare format ' + 'array is the `list-view` spelling and is NOT read here: `object-grid` reads ' + - '`exportOptions.formats`, so an array would be dropped in silence for the csv/json ' + + '`exportOptions.formats`, so an array is silently dropped for the csv/json ' + 'default. Write `{ "formats": ["csv", "xlsx"] }` instead.'; /**