From ecc4890a0ad152536239964b7b768be0d1c91fc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 19:24:09 +0000 Subject: [PATCH] fix(types): ObjectViewSchema table/form slots ship the members they promise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both slots were declared by deriving from the schema they document: table?: Partial>; form?: Partial>; and both derived types declared ZERO properties. `Omit` is `Pick>`, and `keyof T` on a type carrying a string index signature is `string | number` — the literal member names are absorbed. Both source schemas inherit `BaseSchema`'s `[key: string]: any` (objectui#5155), so each `Pick` rebuilt a type holding the index signature and none of the named members. Measured through the TypeScript checker: ObjectGridSchema 61 members, the Omit of it 0; ObjectFormSchema 67, the Omit of it 0. Each `Omit` is now a `Partial>` over an explicit key list — 59 keys for table, 64 for form, every declared member minus the identity keys the view fixes. `Pick` with literal keys never computes `keyof T`, so it cannot collapse. The duplicate-list hazard is neutralised by a checker-driven pin that recomputes each source schema's declared members and requires set equality with the slot, so a member added to ObjectGridSchema and not to the list turns it red. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6269-objectview-slot-key-lists.md | 39 +++ .../object-view-slot-key-lists.test.ts | 247 ++++++++++++++++++ packages/types/src/objectql.ts | 183 ++++++++++++- 3 files changed, 463 insertions(+), 6 deletions(-) create mode 100644 .changeset/6269-objectview-slot-key-lists.md create mode 100644 packages/types/src/__tests__/object-view-slot-key-lists.test.ts diff --git a/.changeset/6269-objectview-slot-key-lists.md b/.changeset/6269-objectview-slot-key-lists.md new file mode 100644 index 000000000..12c8d4b31 --- /dev/null +++ b/.changeset/6269-objectview-slot-key-lists.md @@ -0,0 +1,39 @@ +--- +'@object-ui/types': patch +--- + +`ObjectViewSchema`'s `table` and `form` slots now ship the members they promise +(objectui#6269). Both were declared by deriving from the schema they document — `table?: +Partial>`, `form?: Partial>` — and both derived types declared **zero** properties. + +`Omit` is `Pick>`, and `keyof T` on a type carrying a string index +signature is `string | number` — the literal member names are absorbed. `ObjectGridSchema` and +`ObjectFormSchema` both inherit `BaseSchema`'s `[key: string]: any` (objectui#5155), so each +`Pick` rebuilt a type holding the index signature and none of the named members. Measured +through the TypeScript checker: `ObjectGridSchema` 61 members, the `Omit` of it 0; +`ObjectFormSchema` 67, the `Omit` of it 0. This is objectui#6151's collapse in *property* +position — #6151's guard walks the `LayoutSchema` union and cannot see properties on +`ObjectViewSchema`. + +Nothing errored, because the index signature answered every key as `any`. The visible costs +were the ones only a reader of the declaration meets: `table: { colunms: 3 }` type-checked, +`table: { pageSize: 'ten' }` type-checked, and editor completion inside `table: { … }` offered +nothing at all for a slot documented as "inherits from ObjectGridSchema". + +Each `Omit` is now a `Partial>` over an explicit key list — 59 keys for `table`, 64 for +`form`, i.e. every declared member minus the identity keys the view itself fixes. `Pick` with +literal keys never computes `keyof T`, so it cannot collapse the same way. The key lists are +pinned against silent drift by `packages/types/src/__tests__/object-view-slot-key-lists.test.ts`, +which recomputes each source schema's declared members through the TypeScript checker and +requires set equality; a member added to `ObjectGridSchema` and not to the list turns it red. + +**Tightening, deliberately.** Restoring named members re-enables excess-property checks on +object literals assigned into these two slots, so a misspelled key there is now an error +instead of silently doing nothing. That is the intent of the fix. The slots' member *types* are +unchanged — every key that resolved to a real declared type before still does. + +The `Pick` lists exist only because `BaseSchema` carries a root string index signature. When an +objectui#5155 phase removes it, `Omit` stops collapsing and the lists (plus their pin) become +removable; the pin's own comment records the condition, and one of its assertions is the +tripwire that will notice. diff --git a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts new file mode 100644 index 000000000..e405e4a1c --- /dev/null +++ b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ObjectViewSchema`'s `table` and `form` slots SHIP the members they promise, + * and their key lists cannot drift from the source schemas (objectui#6269). + * + * ## The defect + * + * The two slots were declared by deriving from the schema they document: + * + * table?: Partial>; + * form?: Partial>; + * + * Both derived types declared ZERO properties. `Omit` is + * `Pick>`, and `keyof T` on a type carrying a string + * index signature is `string | number` — the literal member names are ABSORBED. + * `ObjectGridSchema` and `ObjectFormSchema` both inherit `BaseSchema`'s + * `[key: string]: any` (objectui#5155), so each `Pick` rebuilt a type holding + * the index signature and none of the named members. Measured through the + * checker before the fix: + * + * ObjectGridSchema -> 61 members + * Omit -> 0 members + * ObjectFormSchema -> 67 members + * Omit -> 0 members + * + * Nothing errored — the index signature answers every key as `any` — so the + * symptoms were in the tools that READ the declaration: `table: { colunms: 3 }` + * type-checked, `table: { pageSize: 'ten' }` type-checked, and editor completion + * inside `table: { … }` offered nothing at all for a slot documented as + * "inherits from ObjectGridSchema". + * + * This is objectui#6151's collapse in PROPERTY position. #6151's guard + * (`stack-schema-emitted-members.test.ts`) walks the `LayoutSchema` UNION; these + * two are properties on `ObjectViewSchema`, not union members, so that walker + * cannot see them. Hence a second pin rather than an extension of the first. + * + * ## The repair, and the hazard it introduces + * + * Each `Omit` became a `Partial>`. `Pick` with + * LITERAL keys never computes `keyof T`, so it cannot collapse. The cost is a + * hand-written key list that silently drifts the moment a member is added to + * the source schema — a member that exists on `ObjectGridSchema` but is missing + * from `ObjectGridSlotKey` is simply not configurable through the slot, and + * nothing says so. + * + * ⭐ Neutralising that drift is what this file is for. It recomputes each source + * schema's declared members THROUGH THE CHECKER — the same instrument that + * produced the 61 -> 0 measurement — and requires the slot's member set to + * equal exactly "source members minus the identity keys the view fixes". A + * member added to `ObjectGridSchema` and not to the key list turns this red. + * + * ## Why it emits its own declarations instead of reading `dist/` + * + * This repo's per-PR `test` job runs `pnpm test` with NO build ahead of it + * (turbo's `test` task only `dependsOn: ["^build"]` — the DEPENDENCY closure, + * never the package's own build), and `packages/types` has no workspace + * dependencies, so nothing builds it. A guard reading `dist/objectql.d.ts` + * would be absent-or-stale on a cold CI cache — vacuous exactly where it is + * needed. So this file runs the package's OWN tsconfig through the compiler API + * and measures the emitted declaration: the artifact a consumer resolves, + * derived deterministically and with no dependence on CI job ordering. Same + * reasoning as `stack-schema-emitted-members.test.ts` and + * `package-exports-manifest.test.ts`. + * + * ## 🗑️ Removal condition (recorded at triage's request) + * + * These `Pick` lists exist ONLY because `BaseSchema` carries a root string index + * signature. When an objectui#5155 phase removes it, `keyof ObjectGridSchema` + * becomes the literal member union again, `Omit` stops collapsing, and + * `ObjectGridSlotKey` / `ObjectFormSlotKey` — together with this whole file — + * become removable in favour of the original `Omit` form. `declaresStringIndex` + * below is the tripwire that will notice: when it reports `false` for the source + * schemas, the mechanism this file guards is gone. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +/** The identity keys each slot deliberately withholds — the view fixes them. */ +const TABLE_IDENTITY_KEYS = ['type', 'objectName'] as const; +const FORM_IDENTITY_KEYS = ['type', 'objectName', 'mode'] as const; + +/** + * Emit declarations with the package's OWN build settings, into a scratch dir + * under `node_modules/` — which is gitignored, and from which Node's module + * resolution still walks up to `packages/types/node_modules`, so the emitted + * `import type … from '@objectstack/spec/ui'` still resolves. + */ +function emitDeclarations(): { dir: string; objectql: string } { + const configPath = join(packageRoot, 'tsconfig.json'); + const readConfig = ts.readConfigFile(configPath, ts.sys.readFile); + if (readConfig.error) { + throw new Error(ts.flattenDiagnosticMessageText(readConfig.error.messageText, '\n')); + } + const parsed = ts.parseJsonConfigFileContent(readConfig.config, ts.sys, packageRoot); + + const dir = mkdtempSync(join(packageRoot, 'node_modules', '.view-slot-pin-')); + const program = ts.createProgram([join(packageRoot, 'src', 'objectql.ts')], { + ...parsed.options, + outDir: dir, + declaration: true, + emitDeclarationOnly: true, + declarationMap: false, + noEmit: false, + // The real build is `composite`/incremental; neither is meaningful for a + // one-shot emit into a scratch dir, and both would write build info next to + // the package's real artifacts. + composite: false, + incremental: false, + tsBuildInfoFile: undefined, + }); + const emitted = program.emit(); + const objectql = join(dir, 'objectql.d.ts'); + if (!existsSync(objectql)) { + const diagnostics = [...emitted.diagnostics, ...program.getSemanticDiagnostics()] + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n')) + .slice(0, 10); + throw new Error(`declaration emit produced no objectql.d.ts:\n${diagnostics.join('\n')}`); + } + return { dir, objectql }; +} + +const { dir: scratchDir, objectql: emittedObjectql } = emitDeclarations(); +afterAll(() => rmSync(scratchDir, { recursive: true, force: true })); + +const program = ts.createProgram([emittedObjectql], { + noEmit: true, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, +}); +const checker = program.getTypeChecker(); + +function exportedType(name: string): ts.Type { + const sourceFile = program.getSourceFile(emittedObjectql); + const moduleSymbol = sourceFile && checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) throw new Error(`no module symbol for ${emittedObjectql}`); + const symbol = checker + .getExportsOfModule(moduleSymbol) + .find((s) => s.getName() === name); + if (!symbol) throw new Error(`${name} is not exported from the emitted objectql.d.ts`); + return checker.getDeclaredTypeOfSymbol(symbol); +} + +const memberNames = (type: ts.Type): string[] => + checker.getPropertiesOfType(type).map((p) => p.getName()).sort(); + +const declaresStringIndex = (type: ts.Type): boolean => + checker.getIndexInfoOfType(type, ts.IndexKind.String) !== undefined; + +/** The type of one `ObjectViewSchema` slot, with `undefined` stripped. */ +function slotType(slot: 'table' | 'form'): ts.Type { + const view = exportedType('ObjectViewSchema'); + const property = checker.getPropertyOfType(view, slot); + if (!property) throw new Error(`ObjectViewSchema declares no \`${slot}\` member`); + return checker.getNonNullableType(checker.getTypeOfSymbol(property)); +} + +/* ── 1. Degenerate control — the SOURCE schemas still declare their members ── */ + +/** + * ⚠️ Load-bearing. Section 2 compares the slot against the source schema; if + * BOTH collapsed to zero the comparison would pass over two empty sets. These + * numbers are what makes that impossible. They are re-derived here, not + * inherited: bump them deliberately when a member is genuinely added. + */ +describe('the source schemas still declare their full member sets', () => { + it('ObjectGridSchema declares 61 members and carries the #5155 index signature', () => { + const grid = exportedType('ObjectGridSchema'); + expect(memberNames(grid)).toHaveLength(61); + expect(memberNames(grid)).toEqual(expect.arrayContaining(['columns', 'pageSize', 'rowActions'])); + // When this flips to `false`, objectui#5155 has removed the root index + // signature and the `Pick` lists this file pins become removable. + expect(declaresStringIndex(grid)).toBe(true); + }); + + it('ObjectFormSchema declares 67 members and carries the #5155 index signature', () => { + const form = exportedType('ObjectFormSchema'); + expect(memberNames(form)).toHaveLength(67); + expect(memberNames(form)).toEqual(expect.arrayContaining(['fields', 'sections', 'submitText'])); + expect(declaresStringIndex(form)).toBe(true); + }); +}); + +/* ── 2. The measurement — the key lists equal members-minus-identity-keys ─── */ + +describe.each([ + { slot: 'table' as const, source: 'ObjectGridSchema', identity: TABLE_IDENTITY_KEYS }, + { slot: 'form' as const, source: 'ObjectFormSchema', identity: FORM_IDENTITY_KEYS }, +])('ObjectViewSchema.$slot ships $source’s configuration (objectui#6269)', ({ slot, source, identity }) => { + it('declares EXACTLY the source members minus the identity keys the view fixes', () => { + // Before the fix this read `[]` against 59 (table) / 64 (form). + // Set equality, not a spot check: it fails when the slot collapses again, + // AND when a member is added to the source schema without being added to + // the key list (the duplicate-list hazard this pin exists to neutralise). + const expected = memberNames(exportedType(source)).filter( + (k) => !(identity as readonly string[]).includes(k), + ); + expect(memberNames(slotType(slot))).toEqual(expected); + }); + + it('withholds the identity keys the view already fixes', () => { + const declared = memberNames(slotType(slot)); + for (const key of identity) expect(declared).not.toContain(key); + }); + + it('declares NO string index signature — that is what re-opens the defect', () => { + // A `[key: string]: any` here would make every assertion above cosmetic: + // excess-property checks on `table: { … }` literals would stop firing and + // typos would be accepted again, exactly as before the fix. + expect(declaresStringIndex(slotType(slot))).toBe(false); + }); + + it('keeps every member optional (the `Partial` wrapper survived)', () => { + const required = checker + .getPropertiesOfType(slotType(slot)) + .filter((p) => (p.getFlags() & ts.SymbolFlags.Optional) === 0) + .map((p) => p.getName()); + expect(required).toEqual([]); + }); +}); + +/* ── 3. Spot checks — the members an author actually reaches for ─────────── */ + +describe('the slots offer the members their doc comments promise', () => { + it.each(['columns', 'pageSize', 'rowActions', 'selectable', 'sort', 'className'])( + 'table declares `%s` as a named member, not merely via an index signature', + (member) => { + expect(memberNames(slotType('table'))).toContain(member); + }, + ); + + it.each(['fields', 'sections', 'layout', 'submitText', 'readOnly', 'className'])( + 'form declares `%s` as a named member, not merely via an index signature', + (member) => { + expect(memberNames(slotType('form'))).toContain(member); + }, + ); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index bd7397df9..3ebeefacc 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1428,6 +1428,171 @@ export interface ObjectFormSchema extends BaseSchema { }; } +/** + * The `ObjectGridSchema` keys a view's `table` slot may carry: every member + * `ObjectGridSchema` declares, minus the identity keys the view itself fixes + * (`type`, `objectName`). 59 keys. + * + * ⚠️ This is an explicit list, and NOT `Omit`, + * because `Omit` collapses here (objectui#6269). `Omit` is + * `Pick>`, and `keyof T` on a type carrying a string + * index signature is `string | number` — the literal member names are ABSORBED. + * `ObjectGridSchema` inherits `BaseSchema`'s `[key: string]: any` + * (objectui#5155), so the `Omit` rebuilt a type holding the index signature and + * NONE of the 61 named members: measured through the checker, + * `Omit` declared 0 properties. The + * slot accepted anything (`table: { colunms: 3 }` type-checked), offered no + * editor completion, and the doc comment promised an inheritance it did not + * deliver. `Pick` with LITERAL keys never computes `keyof T`, so it cannot + * collapse the same way. + * + * 🔒 The duplicate-list hazard — a member added to `ObjectGridSchema` and not to + * this list — is pinned by + * `src/__tests__/object-view-slot-key-lists.test.ts`, which recomputes the + * source schema's declared members through the TypeScript checker and requires + * set equality with this list. + * + * 🗑️ When a #5155 phase removes `BaseSchema`'s root index signature, `Omit` + * stops collapsing: this list, `ObjectFormSlotKey` below, and their pin all + * become removable in favour of the original `Omit` form. + */ +type ObjectGridSlotKey = + | 'aggregations' + | 'ariaLabel' + | 'batchActions' + | 'body' + | 'bulkActionDefs' + | 'bulkActions' + | 'bulkSpecActions' + | 'children' + | 'className' + | 'columns' + | 'conditionalFormatting' + | 'data' + | 'defaultFilters' + | 'defaultSort' + | 'description' + | 'disabled' + | 'disabledOn' + | 'editable' + | 'emptyState' + | 'exportOptions' + | 'fields' + | 'filter' + | 'frozenColumns' + | 'grouping' + | 'hidden' + | 'hiddenOn' + | 'id' + | 'keyboardNavigation' + | 'label' + | 'name' + | 'navigation' + | 'onNavigate' + | 'operations' + | 'pageSize' + | 'pagination' + | 'placeholder' + | 'reorderableColumns' + | 'resizable' + | 'resizableColumns' + | 'rowActions' + | 'rowColor' + | 'rowHeight' + | 'rowSpecActions' + | 'searchableFields' + | 'selectable' + | 'selection' + | 'showColumnTypeIcons' + | 'showFilters' + | 'showPagination' + | 'showSearch' + | 'singleClickEdit' + | 'sort' + | 'staticData' + | 'style' + | 'testId' + | 'title' + | 'visible' + | 'visibleOn' + | 'visibleWhen'; + +/** + * The `ObjectFormSchema` keys a view's `form` slot may carry: every member + * `ObjectFormSchema` declares, minus the identity keys the view itself fixes + * (`type`, `objectName`, `mode`). 64 keys. + * + * Same mechanism, same pin, same removal condition as `ObjectGridSlotKey` above + * — see its comment. Measured before the fix: + * `Omit` declared 0 of + * `ObjectFormSchema`'s 67 members. + */ +type ObjectFormSlotKey = + | 'allowSkip' + | 'ariaLabel' + | 'body' + | 'buttons' + | 'cancelText' + | 'children' + | 'className' + | 'columns' + | 'customFields' + | 'data' + | 'defaultTab' + | 'defaults' + | 'description' + | 'disabled' + | 'disabledOn' + | 'drawerSide' + | 'drawerWidth' + | 'fields' + | 'formType' + | 'groups' + | 'hidden' + | 'hiddenOn' + | 'id' + | 'initialData' + | 'initialValues' + | 'label' + | 'layout' + | 'mobile' + | 'modalCloseButton' + | 'modalSize' + | 'name' + | 'navigateOnSuccess' + | 'nextText' + | 'onCancel' + | 'onError' + | 'onOpenChange' + | 'onStepChange' + | 'onSuccess' + | 'open' + | 'placeholder' + | 'prevText' + | 'readOnly' + | 'recordId' + | 'resetOnSuccess' + | 'sections' + | 'showCancel' + | 'showReset' + | 'showStepIndicator' + | 'showSubmit' + | 'splitDirection' + | 'splitResizable' + | 'splitSize' + | 'style' + | 'subforms' + | 'submitBehavior' + | 'submitHandler' + | 'submitText' + | 'successMessage' + | 'tabPosition' + | 'testId' + | 'title' + | 'visible' + | 'visibleOn' + | 'visibleWhen'; + /** * ObjectView Schema * A complete object management interface combining ObjectGrid and ObjectForm. @@ -1484,16 +1649,22 @@ export interface ObjectViewSchema extends BaseSchema { navigation?: ViewNavigationConfig; /** - * Table/Grid configuration - * Inherits from ObjectGridSchema + * Table/Grid configuration. + * + * Every `ObjectGridSchema` member except the identity keys this view already + * fixes (`type`, `objectName`) — see `ObjectGridSlotKey` for why the key list + * is spelled out instead of `Omit`-ed (objectui#6269). */ - table?: Partial>; + table?: Partial>; /** - * Form configuration - * Inherits from ObjectFormSchema + * Form configuration. + * + * Every `ObjectFormSchema` member except the identity keys this view already + * fixes (`type`, `objectName`, `mode`) — see `ObjectFormSlotKey` for why the + * key list is spelled out instead of `Omit`-ed (objectui#6269). */ - form?: Partial>; + form?: Partial>; /** * Fields that support text search