From a7e32588c4e3c2cce198a81c93574a54e7eafd8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 15:47:00 +0000 Subject: [PATCH] fix(types): bind the ListView exportOptions mirror to the spec field, drop the ListView casts (#6956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zod mirror of `ListViewSchema` restated a pre-objectstack#8010 shape for `exportOptions` — `'pdf'` accepted in both spellings, no `streaming`, a non-strict object — while the installed `@objectstack/spec@17.2.0` refuses `'pdf'` with an `os migrate meta --from 16` prescription, is strict on the object form and declares `streaming`. Because `ListViewInferred` is `z.input` of the mirror, the `ListViewSchema` type the ListView renderer is written against disagreed with its sibling `ObjectGridSchema['exportOptions']` and the renderer read `streaming` through `as any`. The member is now `SpecListViewSchema.shape.exportOptions` by reference: the spec's two-branch union (bare array lifted to `{ formats }` at parse, strict five-key object), with the spec's own description. `ListViewSchema['exportOptions']` is measured to be exactly `ListViewExportFormat[] | ListViewExportOptions`. ListView: the two `as any` `streaming` reads and the `'pdf'` in the fold's cast are deleted; they compile without annotation. The bare-array fold stays — nothing on the render path parses, so a stored array reaches the renderer un-lifted (objectui#4535 item 4). The parity pin is widened to the mirror: identity with the spec field, the four verdicts the card names (lift / refuse pdf / refuse sixth key / accept streaming with the value surviving), the TS face pins, and a neighbouring member as control. One fixture that authored `'pdf'` under the ListView type (`p1-spec-alignment.test.ts`) drops it — the test is about the object form, not the value. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s --- ...956-listview-export-options-spec-mirror.md | 57 +++++ .../6956-plugin-list-export-options-casts.md | 17 ++ packages/plugin-list/src/ListView.tsx | 6 +- .../export-options-spec-parity.test.ts | 204 ++++++++++++++++-- .../src/__tests__/p1-spec-alignment.test.ts | 4 +- packages/types/src/zod/README.md | 2 +- packages/types/src/zod/objectql.zod.ts | 35 ++- 7 files changed, 288 insertions(+), 37 deletions(-) create mode 100644 .changeset/6956-listview-export-options-spec-mirror.md create mode 100644 .changeset/6956-plugin-list-export-options-casts.md diff --git a/.changeset/6956-listview-export-options-spec-mirror.md b/.changeset/6956-listview-export-options-spec-mirror.md new file mode 100644 index 0000000000..076eb55ce2 --- /dev/null +++ b/.changeset/6956-listview-export-options-spec-mirror.md @@ -0,0 +1,57 @@ +--- +'@object-ui/types': minor +--- + +**Breaking for authored metadata:** the `exportOptions` member of the ListView +zod mirror (`ListViewSchema` in `@object-ui/types/zod`) is now `@objectstack/spec`'s +own `ListViewSchema.shape.exportOptions`, bound by reference rather than +restated (objectui#6956). A `list-view` document that authors the retired `'pdf'` +format — in either spelling, `exportOptions: ['csv', 'pdf']` or +`exportOptions: { formats: ['pdf'] }` — or a sixth key on the object form +(`{ formats: ['csv'], compression: 'gzip' }`) no longer validates through this +package's mirror. It never validated at the platform's publish gate: +`@objectstack/spec` 17.0.0 removed `'pdf'` from the format enum (objectstack#8010; +PDF export itself was declined as objectstack#1301 NOT_PLANNED) and made the +object form strict, so the mirror was passing locally what the platform refuses +with an `os migrate meta --from 16` prescription — an author saw green here and +a refusal upstream. `streaming`, the fifth spec key, is now declared on this face +(the renderer honoured it; no local declaration carried it). + +**What was measured, on this branch's base.** The mirror declared a pre-#8010 +shape of its own — `'pdf'` in both branches, no `streaming`, a non-strict +`z.object` — and `ListViewInferred` is `z.input` of that mirror, so the +`ListViewSchema` TYPE the ListView renderer is written against disagreed with +its sibling `ObjectGridSchema['exportOptions']` (the clean five-key +`ListViewExportOptions`), and the renderer could only read `streaming` through +`as any`. Against the installed pin (`@objectstack/spec@17.2.0`, not a working +tree), `ListViewSchema.shape.exportOptions` from `@objectstack/spec/ui` lifts +`['csv', 'xlsx']` to `{ formats: ['csv', 'xlsx'] }`, refuses `['csv', 'pdf']` +with the migration prescription, refuses `{ formats: ['csv'], compression: 'gzip' }` +(strict), and accepts `{ formats: ['csv'], streaming: true }` with the value +intact. The mirror now IS that schema object, so the four verdicts are the +spec's by construction; `export-options-spec-parity.test.ts` pins the identity, +the four verdicts, and the survival of `streaming` through a parse. + +**The TS face follows.** `ListViewSchema['exportOptions']` is now the spec's +INPUT type: `ListViewExportFormat[] | ListViewExportOptions` — the bare array +stays admissible on input because nothing on the render path parses, and the +object arm IS the same `ListViewExportOptions` that `ObjectGridSchema` and +`NamedListView` carry. One spec key, one type, on every local authoring surface; +`'pdf'` is a compile-time refusal in both spellings. + +**Who is NOT affected.** A document authoring `['csv', 'xlsx']`, +`{ formats: ['csv', 'json'] }` or any combination of the five spec keys is +untouched; absent stays valid; the member's description is now the spec's own +text. The spec's parse-time lift of a bare array to `{ formats }` now runs for +whoever parses through this mirror as well. + +**Migration:** delete `'pdf'` (the surviving formats are `'csv'`, `'xlsx'` and +`'json'`); delete any key outside `formats` / `maxRecords` / `includeHeaders` / +`fileNamePrefix` / `streaming`. `os migrate meta --from 16` lists the mechanical +edits for existing sources. + +Graded `minor`, not `patch`: this narrows the accepted input set, which is +breaking for any author who wrote the tolerated value. 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/.changeset/6956-plugin-list-export-options-casts.md b/.changeset/6956-plugin-list-export-options-casts.md new file mode 100644 index 0000000000..3c632ff3c7 --- /dev/null +++ b/.changeset/6956-plugin-list-export-options-casts.md @@ -0,0 +1,17 @@ +--- +'@object-ui/plugin-list': patch +--- + +`ListView` reads `exportOptions.streaming` without a cast (objectui#6956). The +two `as any` reads — the `exportableFormats` server-availability check and +`handleExport`'s server-eligibility gate — and the `'pdf'` in the bare-array +fold's cast are gone: the `ListViewSchema` type now carries `streaming` and not +`'pdf'`, because `@object-ui/types`' zod mirror binds the spec's `exportOptions` +field by reference. No behaviour change: the same formats are offered, +`streaming: false` still forces the client-side path, and the bare-array fold +(`resolvedExportOptions`, a stored `['csv', 'xlsx']` folded to `{ formats }`) +STAYS — nothing on the render path parses and `ObjectView` forwards a stored +value verbatim, so the spec's parse-time lift never runs before this renderer +and the fold is load-bearing rather than legacy. A `'pdf'` stored before the +retirement still arrives as data and is still dropped from the export menu with +the existing one-time warning. diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index f86652b4de..6c02a48508 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -1301,7 +1301,7 @@ export const ListView = React.forwardRef(({ if (!schema.exportOptions) return undefined; // Spec format: simple string[] like ['csv', 'xlsx'] if (Array.isArray(schema.exportOptions)) { - return { formats: schema.exportOptions as Array<'csv' | 'xlsx' | 'json' | 'pdf'> }; + return { formats: schema.exportOptions }; } // ObjectUI format: already an object return schema.exportOptions; @@ -1321,7 +1321,7 @@ export const ListView = React.forwardRef(({ const declared = resolvedExportOptions?.formats || ['csv', 'json']; const serverAvailable = typeof dataSource?.exportDownload === 'function' && !!schema.objectName - && (resolvedExportOptions as any)?.streaming !== false; + && resolvedExportOptions?.streaming !== false; const supported = serverAvailable ? ['csv', 'xlsx', 'json'] : ['csv', 'json']; return declared.filter((f: string) => supported.includes(f)); }, [resolvedExportOptions, dataSource, schema.objectName]); @@ -2973,7 +2973,7 @@ export const ListView = React.forwardRef(({ const serverEligible = (format === 'csv' || format === 'xlsx' || format === 'json') && typeof dataSource?.exportDownload === 'function' && !!schema.objectName - && (exportConfig as any)?.streaming !== false; + && exportConfig?.streaming !== false; if (serverEligible) { const fields = effectiveFields .map((f: any) => columnIdentity(f)) diff --git a/packages/types/src/__tests__/export-options-spec-parity.test.ts b/packages/types/src/__tests__/export-options-spec-parity.test.ts index 329384b24f..cb03b5dfe1 100644 --- a/packages/types/src/__tests__/export-options-spec-parity.test.ts +++ b/packages/types/src/__tests__/export-options-spec-parity.test.ts @@ -1,13 +1,14 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * `ListViewExportOptions` ↔ the INSTALLED `@objectstack/spec` (objectui#4535). + * `exportOptions` ↔ the INSTALLED `@objectstack/spec`, on BOTH local faces + * (objectui#4535 for the TS declaration, objectui#6956 for the zod mirror). * - * The card this file closes was filed because a comment claimed alignment with - * `@objectstack/spec`'s `ListViewSchema.exportOptions` and was false in both - * directions. objectstack#8010 fixed the upstream half; objectui's half restated - * the spec's five keys locally, under a NOTE explaining that the object form was - * not importable from the pin. + * The card this file was written for was filed because a comment claimed + * alignment with `@objectstack/spec`'s `ListViewSchema.exportOptions` and was + * false in both directions. objectstack#8010 fixed the upstream half; objectui's + * half restated the spec's five keys locally, under a NOTE explaining that the + * object form was not importable from the pin. * * Both halves have since moved, and that is the hazard this file exists for. The * pin bumped to `@objectstack/spec@17.2.0`, which DOES carry the object form — @@ -21,23 +22,41 @@ * restatement is a third copy, and the copy is what drifts (the lesson * `list-view-spec-parity.test.ts` already records for the enclosing schema). * - * Why a mirror at all, rather than `z.infer` of the spec symbol: - * `ListViewExportOptionsSchema` is internal to the spec bundle and NOT among the - * package's public exports — measured, not assumed, by the floor test below. - * Only the enclosing `ListViewSchema` is exported, and its `exportOptions` is a - * two-branch union (legacy-array lift ∪ object), whose inferred type is a union - * and not this interface. When upstream exports the symbol, derive from it and - * delete both the mirror and this file's key-set tests. + * Why the TS interface is a mirror at all, rather than `z.infer` of the spec + * symbol: `ListViewExportOptionsSchema` is internal to the spec bundle and NOT + * among the package's public exports — measured, not assumed, by the floor test + * below. Only the enclosing `ListViewSchema` is exported, and its `exportOptions` + * is a two-branch union (legacy-array lift ∪ object), whose inferred type is a + * union and not this interface. When upstream exports the symbol, derive from it + * and delete both the mirror and this file's key-set tests. * - * SCOPE, stated rather than implied: this covers the TypeScript declaration in - * `objectql.ts`. objectui's own Zod mirror of `ListViewSchema` - * (`src/zod/objectql.zod.ts`) declares `exportOptions` separately and is NOT - * measured here — deliberately, because it is a different seat's file surface. + * The ZOD mirror has no such excuse, and since objectui#6956 it makes none: + * `ListViewSchema.exportOptions` in `src/zod/objectql.zod.ts` is the spec field + * BOUND BY REFERENCE (`SpecListViewSchema.shape.exportOptions`), not a + * restatement. Before that it declared a pre-#8010 shape of its own — `'pdf'` + * accepted in both spellings, no `streaming`, a non-strict object — and because + * `ListViewInferred` is `z.input` of that mirror, the `ListViewSchema` TYPE the + * ListView renderer is written against disagreed with its sibling + * `ObjectGridSchema['exportOptions']`. The second `describe` below pins the + * mirror: identity with the spec field, the four verdicts the card names, the + * survival of `streaming` through a parse, and the TS face that follows. + * + * SCOPE, stated rather than implied: the first `describe` covers the TypeScript + * declaration in `objectql.ts`; the second covers the zod mirror and the + * `ListViewSchema` type derived from it. Both are `@object-ui/types` surfaces. */ import { describe, it, expect } from 'vitest'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; -import type { ListViewExportFormat, ListViewExportOptions } from '../index'; +import { ListViewSchema as MirrorListViewSchema } from '../zod/objectql.zod.js'; +import type { ListViewExportFormat, ListViewExportOptions, ListViewSchema } from '../index'; + +/* ── Type-level helpers (house form: `objectql.exportOptions.test.ts`) ────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +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 local declaration, as runtime-enumerable data ────────────────────── */ @@ -69,7 +88,11 @@ type ZodLike = { options?: ZodLike[]; shape?: Record; element?: ZodLike; - safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { issues: { message: string }[] } }; + safeParse: (v: unknown) => { + success: boolean; + data?: unknown; + error?: { issues: { message: string; path: PropertyKey[] }[] }; + }; _zod?: { def?: { type?: string; element?: ZodLike; entries?: Record } }; }; @@ -101,7 +124,7 @@ function specFormatEnumValues(): string[] { return Object.keys(element?._zod?.def?.entries ?? {}); } -/* ── Tests ───────────────────────────────────────────────────────────────── */ +/* ── Tests: the TS declaration ───────────────────────────────────────────── */ describe('exportOptions ↔ installed @objectstack/spec (objectui#4535)', () => { it('finds the spec shape it is about to compare against', () => { @@ -115,7 +138,7 @@ describe('exportOptions ↔ installed @objectstack/spec (objectui#4535)', () => expect(specFormatEnumValues().length).toBeGreaterThan(0); }); - it('records WHY the mirror exists: the spec does not export the symbol', async () => { + it('records WHY the TS mirror exists: the spec does not export the symbol', async () => { // The reason in the doc comment, measured. If this ever fails, the mirror is // obsolete: derive `ListViewExportOptions` from the exported symbol and // delete it, rather than leaving a hand copy beside an importable schema. @@ -167,3 +190,142 @@ describe('exportOptions ↔ installed @objectstack/spec (objectui#4535)', () => expect(lifted.data).toEqual({ formats: ['csv', 'xlsx'] }); }); }); + +/* ── Tests: the zod mirror and the ListView TS face it derives ───────────── */ + +/** + * The `ListViewSchema` TYPE's `exportOptions`, `undefined` stripped. This is + * `z.input` of the mirror member — i.e. what the ListView renderer is written + * against — so the pins below are about the renderer's contract, not a test + * fixture's. + */ +type ListViewExportFace = NonNullable< ListViewSchema['exportOptions'] >; + +/** + * The face is the spec's INPUT type: the bare format array (the legacy + * spelling, admitted on input because nothing on the render path parses) OR the + * five-key object — and the object arm IS `ListViewExportOptions`, the same type + * `ObjectGridSchema['exportOptions']` and `NamedListView['exportOptions']` carry. + * One spec key, one type, on every local authoring surface. + */ +type _FaceIsTheSpecInput = Expect< Equal< ListViewExportFace, ListViewExportFormat[] | ListViewExportOptions > >; + +/** `streaming` is on the face, as the spec's optional boolean — no cast needed to read it. */ +type _FaceCarriesStreaming = Expect< + Equal< Exclude< ListViewExportFace, unknown[] >['streaming'], boolean | undefined > +>; + +/** The array arm's vocabulary is the spec's three formats — `'pdf'` is not among them. */ +type _ArrayArmIsCsvXlsxJson = Expect< + Equal< Extract< ListViewExportFace, unknown[] >[number], 'csv' | 'xlsx' | 'json' > +>; + +/** A minimal, valid `list-view` node — the envelope every parse below rides on. */ +const NODE = { type: 'list-view', objectName: 'accounts' } as const; + +/** The four readings the card names, in the order it names them. */ +const FOUR_READINGS: readonly unknown[] = [ + ['csv', 'xlsx'], + ['csv', 'pdf'], + { formats: ['csv'], compression: 'gzip' }, + { formats: ['csv'], streaming: true }, +]; + +describe('the zod mirror binds exportOptions to the spec field (objectui#6956)', () => { + const mirrorExportOptions = (MirrorListViewSchema as unknown as { shape: Record }) + .shape.exportOptions; + + it('is the spec field BY REFERENCE — the same schema object, not a restatement', () => { + // The reading that makes the derivation real. A restated copy — even a + // verbatim one — passes every verdict test below on the day it is written + // and drifts afterwards; identity cannot. + expect(mirrorExportOptions).toBeDefined(); + expect(mirrorExportOptions).toBe(specExportOptions); + }); + + it('lifts a bare format array to `{ formats }` at parse, and still admits the array on the INPUT type', () => { + const lifted = MirrorListViewSchema.safeParse({ ...NODE, exportOptions: ['csv', 'xlsx'] }); + expect(lifted.success).toBe(true); + expect(lifted.data?.exportOptions).toEqual({ formats: ['csv', 'xlsx'] }); + // The TS face: an authored bare array is a legal INPUT. Compile-time pin — + // `satisfies` refuses the literal if the array arm leaves `z.input`. + const authored = ['csv', 'xlsx'] satisfies ListViewSchema['exportOptions']; + expect(Array.isArray(authored)).toBe(true); + }); + + it("refuses 'pdf' in both spellings, on the `exportOptions` path, with the migration prescription", () => { + const spellings: readonly unknown[] = [['csv', 'pdf'], { formats: ['csv', 'pdf'] }]; + for (const exportOptions of spellings) { + const refused = MirrorListViewSchema.safeParse({ ...NODE, exportOptions }); + 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'); + expect(messages).toMatch(/pdf/); + expect(messages).toMatch(/8010|1301/); + } + // And the TYPE refuses it too, in both spellings — the renderer's contract + // cannot be handed a value the platform refuses at publish. + // @ts-expect-error 'pdf' left the spec's format enum in @objectstack/spec 17.0.0 (objectstack#8010) + const pdfArray: ListViewSchema['exportOptions'] = ['csv', 'pdf']; + // @ts-expect-error 'pdf' left the spec's format enum in @objectstack/spec 17.0.0 (objectstack#8010) + const pdfObject: ListViewSchema['exportOptions'] = { formats: ['pdf'] }; + expect([pdfArray, pdfObject]).toHaveLength(2); + }); + + it('refuses a sixth key on the object branch — strict, as upstream, no silent strip', () => { + const refused = MirrorListViewSchema.safeParse({ + ...NODE, exportOptions: { formats: ['csv'], compression: 'gzip' }, + }); + expect(refused.success).toBe(false); + expect((refused.error?.issues ?? []).some((i) => i.path[0] === 'exportOptions')).toBe(true); + // The TYPE refuses it as well. + // @ts-expect-error `compression` is not one of the spec's five exportOptions keys + const sixth: ListViewSchema['exportOptions'] = { formats: ['csv'], compression: 'gzip' }; + expect(sixth).toBeDefined(); + }); + + it('accepts `streaming: true` and `streaming: false`, and the value SURVIVES the parse', () => { + // A non-strict `z.object` would accept both and strip the key — green on + // `success`, silently dropping the opt-out the renderer honours. Assert the + // value comes back, not just that the parse passed. + for (const streaming of [true, false] as const) { + const parsed = MirrorListViewSchema.safeParse({ ...NODE, exportOptions: { formats: ['csv'], streaming } }); + expect(parsed.success).toBe(true); + const out = parsed.data?.exportOptions as { streaming?: boolean } | undefined; + expect(out?.streaming).toBe(streaming); + } + // And the face admits it without a cast (compile-time pin). + const declared = { formats: ['csv'], streaming: false } satisfies ListViewSchema['exportOptions']; + expect(declared.streaming).toBe(false); + }); + + it('agrees with the spec verdict-for-verdict, and output-for-output, on the four readings', () => { + const verdicts = FOUR_READINGS.map((input) => { + const spec = specExportOptions.safeParse(input); + const mirror = mirrorExportOptions.safeParse(input); + expect(mirror.success).toBe(spec.success); + expect(mirror.data).toEqual(spec.data); + return spec.success; + }); + // Non-vacuity: these are the four readings the card names, with the four + // verdicts it records — lift, refuse, refuse, accept. A spec whose + // `exportOptions` accepted everything would agree with itself trivially. + expect(verdicts).toEqual([true, false, false, true]); + }); + + it('control: neighbouring members keep their accept sets', () => { + // `conditionalFormatting` sits next to `exportOptions` in the same + // `.extend()` block and is untouched by objectui#6956: both of its shapes + // still parse and a non-array is still refused. + const cf = MirrorListViewSchema.shape.conditionalFormatting; + expect(cf.safeParse([{ field: 'status', operator: 'equals', value: 'open' }]).success).toBe(true); + expect(cf.safeParse([{ condition: '${record.amount > 100}', style: { color: 'red' } }]).success).toBe(true); + expect(cf.safeParse('nope').success).toBe(false); + // `allowExport`, the local boolean the export menu is gated on. + expect(MirrorListViewSchema.shape.allowExport.safeParse(true).success).toBe(true); + expect(MirrorListViewSchema.shape.allowExport.safeParse('yes').success).toBe(false); + // And the envelope itself: a node with no `exportOptions` at all is unchanged. + expect(MirrorListViewSchema.safeParse(NODE).success).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/p1-spec-alignment.test.ts b/packages/types/src/__tests__/p1-spec-alignment.test.ts index f11c028047..be5df027df 100644 --- a/packages/types/src/__tests__/p1-spec-alignment.test.ts +++ b/packages/types/src/__tests__/p1-spec-alignment.test.ts @@ -237,7 +237,7 @@ describe('P1.1 ListView Spec Alignment', () => { type: 'list-view', objectName: 'Account', exportOptions: { - formats: ['csv', 'json', 'pdf'], + formats: ['csv', 'json'], maxRecords: 5000, includeHeaders: true, fileNamePrefix: 'accounts_export', @@ -245,7 +245,7 @@ describe('P1.1 ListView Spec Alignment', () => { }; expect(Array.isArray(schema.exportOptions)).toBe(false); const opts = schema.exportOptions as { formats?: string[]; maxRecords?: number }; - expect(opts.formats).toEqual(['csv', 'json', 'pdf']); + expect(opts.formats).toEqual(['csv', 'json']); expect(opts.maxRecords).toBe(5000); }); diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 28916d4964..9da4a0fb53 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -19,7 +19,7 @@ This directory contains runtime validation schemas using [Zod](https://github.co Only the component envelope (`type: 'list-view'` + `objectName`), the legacy objectui vocabulary (`viewType`/`fields`/`filters`/`show*`/`densityMode`/…), and the handful of configs whose objectui shape is intentionally broader than spec's (`userFilters`, -`sharing`, `aria`, `conditionalFormatting`, `exportOptions`, and the per-view-type +`sharing`, `aria`, `conditionalFormatting`, and the per-view-type `kanban`/`calendar`/`gantt`/`gallery`/`timeline`) are declared locally on top. The TS type is `z.infer & ListViewRuntimeProps`. A drift-guard test (`__tests__/list-view-spec-parity.test.ts`) fails if the spec grows a field objectui diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 70fec93c61..b20e082ba3 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -297,7 +297,8 @@ const UserFiltersSchema = z.object({ * - legacy vocabulary kept for back-compat: `viewType` (renamed spec `type`), * `fields`/`columns`, `filters`, the `show*` toolbar flags, `densityMode`, `color`, …; * - configs whose objectui shape is intentionally broader than spec's (migration - * deferred): `userFilters`, `sharing`, `aria`, `conditionalFormatting`, `exportOptions`. + * deferred): `userFilters`, `sharing`, `aria`, `conditionalFormatting` + * (`exportOptions` left this list with objectui#6956 — it is the spec field by reference). * * The per-view-type configs (`kanban`/`calendar`/`gantt`/`gallery`/`timeline`) are no * longer forks: they derive from the spec configs below, keeping only `calendar.defaultView` @@ -511,15 +512,29 @@ export const ListViewSchema = BaseSchema style: z.record(z.string(), z.string()), }), ])).optional().describe('Conditional formatting rules'), - exportOptions: z.union([ - z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])), - z.object({ - formats: z.array(z.enum(['csv', 'xlsx', 'json', 'pdf'])).optional(), - maxRecords: z.number().optional(), - includeHeaders: z.boolean().optional(), - fileNamePrefix: z.string().optional(), - }), - ]).optional().describe('Export options'), + // `exportOptions` — the spec's own field, BY REFERENCE (objectui#6956). + // + // `ListViewExportOptionsSchema` is internal to the spec bundle (not a public + // export — measured by `__tests__/export-options-spec-parity.test.ts`), but + // the enclosing `ListViewSchema.shape.exportOptions` IS a live export, and it + // is the whole contract: a two-branch union of the bare format array (the + // legacy spelling, lifted to `{ formats }` at parse) and the STRICT five-key + // object (`formats` / `maxRecords` / `includeHeaders` / `fileNamePrefix` / + // `streaming`), with `'pdf'` refused in both spellings under an + // `os migrate meta --from 16` prescription (objectstack#8010). + // + // This member used to restate a pre-#8010 shape — `'pdf'` accepted in both + // branches, no `streaming`, a non-strict object — so `ListViewInferred` + // (`z.input` of this schema, and through it the `ListViewSchema` TYPE the + // ListView renderer is written against) disagreed with its sibling + // `ObjectGridSchema['exportOptions']`, and the renderer could only read + // `streaming` through `as any`. Binding the spec field by reference makes + // the two faces one contract again and keeps the description the spec + // wrote. The bare array stays admissible on the INPUT type on purpose: + // nothing on the render path parses, so a stored array reaches `ListView` + // un-lifted and its `resolvedExportOptions` fold is load-bearing + // (objectui#4535 item 4). + exportOptions: SpecListViewSchema.shape.exportOptions, // Per-view-type configs — spec-derived (see the definitions above #2231). // `gantt` is NOT here: it flows in from the spec fields unmodified. kanban: KanbanConfig.optional().describe('Kanban-specific configuration'),