From 9a1e360579b27fc1ed25b82bf5ff5d8c866fac49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:29:19 +0000 Subject: [PATCH 1/9] feat(types): declare the read keys the strict authoring face refused (objectui#11070) `StrictAnyComponentSchema` refused keys a registered renderer reads, so the strict `objectui validate` would tell an author to delete a key that works. Declare, on the TypeScript face and the zod mirror, the ones whose read, spelling and value shape are settled: - `form.showSubmit` (the form renderer's submit-button switch, default true); - `form.fields[]`: `multiple`, `rows`, `accept`, `dimensions`, `min`, `max`, `minLength`, `maxLength` (the spec's `FieldSchema` members, by reference) and `pattern` (a string), the field metadata a hand-authored form writes on the entry the renderer hands each field widget as its metadata carrier; - `dataSource` on `object-grid`, `list-view`, `object-form` and `object-kanban`: the spec's `ElementDataSourceSchema`, by reference, read off the node through `ElementDataSourceGate`. The object-view `table` slot withholds `dataSource` as a record source the view owns (the objectui#10976 rule); the `form` slot carries it, as it carries `bind` and `data`. Ledger rows the declarations move are updated. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm --- .../__tests__/form-field-zod-coverage.test.ts | 12 ++ .../__tests__/imported-defaults-8317.test.ts | 14 +- .../__tests__/list-view-spec-parity.test.ts | 31 ++- .../object-view-slot-key-lists.test.ts | 15 +- .../strict-face-read-keys-11070.test.ts | 183 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 35 +++- packages/types/src/form.ts | 95 +++++++++ packages/types/src/objectql.ts | 48 ++++- packages/types/src/zod/form.zod.ts | 22 ++- packages/types/src/zod/imported-defaults.ts | 5 +- packages/types/src/zod/objectql.zod.ts | 39 ++++ 11 files changed, 480 insertions(+), 19 deletions(-) create mode 100644 packages/types/src/__tests__/strict-face-read-keys-11070.test.ts diff --git a/packages/types/src/__tests__/form-field-zod-coverage.test.ts b/packages/types/src/__tests__/form-field-zod-coverage.test.ts index 1981d05bd0..18d3fa2371 100644 --- a/packages/types/src/__tests__/form-field-zod-coverage.test.ts +++ b/packages/types/src/__tests__/form-field-zod-coverage.test.ts @@ -77,6 +77,18 @@ const DECLARED_KEYS = [ 'span', // objectui#6236 — the section grouping claim (section-divider rows only). 'fields', + // objectui#11070 — field metadata a hand-authored form writes on the entry + // itself, read off it by the field widgets and the built-in branches. All + // but `pattern` are the spec's `FieldSchema` members by reference. + 'multiple', + 'rows', + 'accept', + 'dimensions', + 'min', + 'max', + 'minLength', + 'maxLength', + 'pattern', ]; describe('FormFieldSchema covers the FormField contract', () => { diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 7f55e132bf..09c7cee28f 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -109,7 +109,7 @@ import { checkListViewCalendarVisualization, checkPageSourceCompleteness, } from '@objectstack/spec/ui'; -import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; +import { FieldSchema as SpecFieldSchema, SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { stripImportedDefaults } from '../zod/imported-defaults.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -268,6 +268,11 @@ const IMPORTED: Array = [ // like every other one. ['ElementNumberPropsSchema', SpecElementNumberPropsSchema], ['ElementDataSourceSchema', SpecElementDataSourceSchema], + // objectui#11070: `FormFieldSchema` reads eight of the spec's `FieldSchema` + // members by reference (the field metadata a hand-authored form writes on + // the entry itself), and `multiple` carries the spec's `.default(false)` — + // exactly what this boundary exists to keep out of a parse output. + ['FieldSchema', SpecFieldSchema], ] as const; /** The subset that actually carries an imported default — where the strip does work. */ @@ -339,14 +344,15 @@ describe('the import boundary strips every imported default (objectui#8317)', () it('the walker docblock\'s `lazy` count is re-derived, not quoted', () => { // The `lazy` arm is the one place the identity property cannot hold: it // must rebuild without forcing the getter, so a clean subtree behind a - // `z.lazy` is rebuilt anyway. The module's docblock names FOUR such - // nodes (the fourth, `ViewSchema`'s form-field group, since objectui#7928) + // `z.lazy` is rebuilt anyway. The module's docblock names FIVE such + // nodes (the fourth, `ViewSchema`'s form-field group, since objectui#7928; + // the fifth, under `FieldSchema.relatedListFilter`, since objectui#11070) // and says the exception costs nothing extra for every owner but the // ones `REBUILT_CLEAN` names (objectui#10872 batch 2), because each // other owner sits inside a schema that is being rebuilt regardless. // Both halves are measured here, so a spec bump or an import that moves // either one is red rather than quietly making the docblock false. - expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(4); + expect(walk(IMPORTED.map(([, s]) => s)).lazies).toBe(5); const lazyOwners = IMPORTED.filter(([, s]) => walk([s]).lazies > 0); expect(lazyOwners.length, 'no schema owns a lazy — the count above found them elsewhere').toBeGreaterThan(0); // The clean owners are exactly the named set: rebuilt (so NOT the spec's diff --git a/packages/types/src/__tests__/list-view-spec-parity.test.ts b/packages/types/src/__tests__/list-view-spec-parity.test.ts index 7daddf5183..f8ec7aff32 100644 --- a/packages/types/src/__tests__/list-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/list-view-spec-parity.test.ts @@ -132,6 +132,21 @@ const HANDLER_KEY_REFUSALS = new Set([ 'onPageSizeChange', ]); +/** + * A FOURTH category (objectui#11070): spec members that belong to the page + * COMPONENT envelope rather than to the list view it carries. + * + * `dataSource` is `@objectstack/spec`'s `PageComponentSchema.dataSource` — the + * per-element binding every page component may carry — so the spec's + * `ListViewSchema` (a view config) has no such member, and this arm, being + * the `list-view` NODE, declares it. Neither branch of the #2231 decision + * fits: it is not objectui-only, and it cannot be promoted into the spec's + * view schema because the spec already owns it one level up. It arrives by + * reference to the spec's `ElementDataSourceSchema`; the test below checks + * that it behaves as that binding, so this set cannot park a local field. + */ +const PAGE_COMPONENT_ENVELOPE = new Set(['dataSource']); + describe('ListView spec parity (#2231 drift guard)', () => { it('covers every @objectstack/spec ListView field (spec cannot grow a field objectui ignores)', () => { // Fails when the spec adds a field that objectui neither imports nor envelope-owns — @@ -154,11 +169,25 @@ describe('ListView spec parity (#2231 drift guard)', () => { !specShape[k] && !ENVELOPE.has(k) && !SANCTIONED_LOCAL.has(k) && - !HANDLER_KEY_REFUSALS.has(k), + !HANDLER_KEY_REFUSALS.has(k) && + !PAGE_COMPONENT_ENVELOPE.has(k), ); expect(rogue).toEqual([]); }); + it('every PAGE_COMPONENT_ENVELOPE member is the spec binding — the set cannot hide a local field (objectui#11070)', () => { + const node = (extra: Record) => ({ type: 'list-view', objectName: 'accounts', ...extra }); + for (const key of PAGE_COMPONENT_ENVELOPE) { + expect(ouiKeys.has(key), `${key} is listed but not declared on the arm`).toBe(true); + // The spec's binding: a named object with the binding's own keys parses… + expect(OuiListViewSchema.safeParse(node({ [key]: { object: 'account', view: 'hot', limit: 10 } })).success).toBe(true); + // …a binding that names no object does not (the spec requires `object`)… + expect(OuiListViewSchema.safeParse(node({ [key]: { view: 'hot' } })).success).toBe(false); + // …and neither does an adapter-shaped value, which is not metadata at all. + expect(OuiListViewSchema.safeParse(node({ [key]: 'objectstack' })).success).toBe(false); + } + }); + it('every HANDLER_KEY_REFUSALS member really refuses — the set cannot hide an authorable field', () => { const node = (extra: Record) => ({ type: 'list-view', 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 index 955dee2957..f4adc93aee 100644 --- a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts +++ b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts @@ -26,10 +26,13 @@ * * ⚠️ Those two member counts are the HISTORICAL reading that produced this * pin, kept verbatim because the `-> 0` half is only legible beside them. The - * LIVE counts are 62 and 68: objectui#6357 declared `bind` on `BaseSchema`, + * LIVE counts are 63 and 69: objectui#6357 declared `bind` on `BaseSchema`, * both schemas inherit it, and this guard turned red naming them — which is * precisely the drift it exists to catch. The key was added to both slot * unions in the same change, so the slots still ship the full configuration. + * objectui#11070 then declared `dataSource` (the spec's per-element binding) + * on both source schemas and the guard named it again: the `form` slot carries + * it, and the `table` slot withholds it as a record source the view owns. * * Nothing errored — the index signature answers every key as `any` — so the * symptoms were in the tools that READ the declaration: `table: { colunms: 3 }` @@ -125,7 +128,7 @@ const TABLE_WITHHELD_BY_REASON = { /** `ObjectGrid` has no read of it, so nothing could draw it. */ unread: ['bulkSpecActions', 'description', 'emptyState', 'keyboardNavigation', 'name', 'placeholder', 'rowSpecActions', 'showFilters'], /** The view owns it: its own record source, its own row click, its grid's identity. */ - viewOwned: ['bind', 'data', 'id', 'navigation', 'onNavigate', 'staticData'], + viewOwned: ['bind', 'data', 'dataSource', 'id', 'navigation', 'onNavigate', 'staticData'], /** * A node-level `BaseSchema` key. `ObjectView` draws its grid as a component, * not as a schema node, so no renderer applies one of these to it. @@ -241,18 +244,18 @@ function slotType(slot: 'table' | 'form'): ts.Type { * inherited: bump them deliberately when a member is genuinely added. */ describe('the source schemas still declare their full member sets', () => { - it('ObjectGridSchema declares 62 members and carries the #5155 index signature', () => { + it('ObjectGridSchema declares 63 members and carries the #5155 index signature', () => { const grid = exportedType('ObjectGridSchema'); - expect(memberNames(grid)).toHaveLength(62); + expect(memberNames(grid)).toHaveLength(63); 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 68 members and carries the #5155 index signature', () => { + it('ObjectFormSchema declares 69 members and carries the #5155 index signature', () => { const form = exportedType('ObjectFormSchema'); - expect(memberNames(form)).toHaveLength(68); + expect(memberNames(form)).toHaveLength(69); expect(memberNames(form)).toEqual(expect.arrayContaining(['fields', 'sections', 'submitText'])); expect(declaresStringIndex(form)).toBe(true); }); diff --git a/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts new file mode 100644 index 0000000000..d76f896388 --- /dev/null +++ b/packages/types/src/__tests__/strict-face-read-keys-11070.test.ts @@ -0,0 +1,183 @@ +/** + * 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. + */ + +/** + * Keys a registered renderer READS are declared on both faces, so the strict + * authoring face stops refusing them (objectui#11070). + * + * ## The defect + * + * `StrictAnyComponentSchema` (objectui#8345) closes every object, so a key no + * schema declares is refused by name. PR objectui#11069's measurement of the + * shipped face over the repository's own documents found keys refused that way + * while a registered renderer reads them — `objectui validate` on the strict + * face would tell an author to delete a key that works. This card declares the + * ones whose read, spelling and value shape are settled: + * + * - `form.showSubmit` — the `form` renderer's submit-button switch; + * - `form.fields[]` — field metadata a hand-authored form writes on the entry + * itself (`multiple`, `rows`, `accept`, `dimensions`, `min`, `max`, + * `minLength`, `maxLength`, `pattern`), which the renderer hands each field + * widget as its metadata carrier; + * - `dataSource` on `object-grid`, `list-view`, `object-form` and + * `object-kanban` — the spec's per-element binding, which the registered + * renderers read off the node through `ElementDataSourceGate`. + * + * Each read is reasoned on the TypeScript member that declares it. + * + * ## What each block below holds + * + * 1. a document carrying the key parses on the strict face, and a misspelled + * sibling is still refused BY NAME at the same path — the control that + * shows the face did not open up; + * 2. the declared key is judged by its declared type on BOTH faces; + * 3. the read keys this card deliberately did NOT declare are still refused + * on the strict face — each waits on a ruling the card records; + * 4. the TypeScript faces type the binding, not `any` (type-level, read by + * `tsc -p tsconfig.test.json` only — vitest does not typecheck). + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import type { ListViewSchema, ObjectFormSchema, ObjectGridSchema, ObjectKanbanSchema } from '../objectql.js'; +import type { FormField, FormSchema } from '../form.js'; +import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; + +type Issue = { code: string; path: PropertyKey[]; keys?: string[]; errors?: Issue[][] }; + +const issuesOf = (schema: z.ZodType, doc: unknown): Issue[] | null => { + const result = schema.safeParse(doc); + return result.success ? null : (result.error.issues as unknown as Issue[]); +}; + +/** Every `unrecognized_keys` finding in the issue tree, union arms included, as `path.key`. */ +const undeclared = (issues: Issue[] | null, prefix: PropertyKey[] = []): string[] => { + const out: string[] = []; + for (const issue of issues ?? []) { + const at = [...prefix, ...issue.path]; + if (issue.code === 'unrecognized_keys') for (const k of issue.keys ?? []) out.push([...at, k].map(String).join('.')); + for (const arm of issue.errors ?? []) out.push(...undeclared(arm, at)); + } + return [...new Set(out)]; +}; + +const form = (field: Record, node: Record = {}) => ({ + type: 'form', + ...node, + fields: [{ name: 'f', label: 'F', ...field }], +}); + +/* ── 1. accepted on the strict face; a misspelling beside it is not ─────── */ + +describe('objectui#11070 — the declared read keys parse on the strict face', () => { + it('`form.showSubmit` parses; `showSubmitt` beside it is refused by name', () => { + expect(issuesOf(StrictAnyComponentSchema, form({ type: 'text' }, { showSubmit: false }))).toBeNull(); + expect(undeclared(issuesOf(StrictAnyComponentSchema, form({ type: 'text' }, { showSubmit: false, showSubmitt: true })))) + .toEqual(['showSubmitt']); + }); + + const FIELD_CASES: ReadonlyArray]> = [ + ['multiple', { type: 'file', multiple: true }], + ['rows', { type: 'textarea', rows: 6 }], + ['accept', { type: 'file', accept: ['application/pdf'] }], + ['dimensions', { type: 'vector', dimensions: 768 }], + ['min', { type: 'number', min: 0 }], + ['max', { type: 'number', max: 120 }], + ['minLength', { type: 'input', minLength: 3 }], + ['maxLength', { type: 'input', maxLength: 20 }], + ['pattern', { type: 'input', pattern: '^[^@]+@[^@]+$' }], + ]; + + it.each(FIELD_CASES)('`fields[].%s` parses; a misspelled sibling is refused at the field', (key, field) => { + expect(issuesOf(StrictAnyComponentSchema, form(field))).toBeNull(); + expect(undeclared(issuesOf(StrictAnyComponentSchema, form({ ...field, [`${key}x`]: 1 })))) + .toEqual([`fields.0.${key}x`]); + }); + + const BINDING = { object: 'task', filter: { project: 'acme' } }; + const BOUND_NODES: ReadonlyArray]> = [ + ['object-grid', { objectName: 'task' }], + ['list-view', { objectName: 'task' }], + ['object-form', { objectName: 'task', mode: 'edit' }], + // `objectName` on every node, deliberately: whether a binding's `object` + // may stand in for the node's own required record source (as the spec's + // props gate lets it on `element:number`) is a separate question from + // whether the key is declared, and this block measures only the second. + ['object-kanban', { objectName: 'task' }], + ]; + + it.each(BOUND_NODES)('`%s.dataSource` parses; `dataSourc` beside it is refused by name', (type, rest) => { + expect(issuesOf(StrictAnyComponentSchema, { type, ...rest, dataSource: BINDING })).toBeNull(); + expect(undeclared(issuesOf(StrictAnyComponentSchema, { type, ...rest, dataSource: BINDING, dataSourc: BINDING }))) + .toEqual(['dataSourc']); + }); +}); + +/* ── 2. judged by the declared type, on both faces ───────────────────────── */ + +describe('objectui#11070 — a declared key is judged by its declared type on both faces', () => { + const WRONG: ReadonlyArray = [ + ['a string `showSubmit`', form({ type: 'text' }, { showSubmit: 'false' })], + ['a bare-string `accept` (the spec types it as an array)', form({ type: 'file', accept: 'application/pdf' })], + ['a zero `rows` (the spec requires a positive integer)', form({ type: 'textarea', rows: 0 })], + ['a numeric `pattern`', form({ type: 'input', pattern: 5 })], + ['a binding that names no `object`', { type: 'object-kanban', dataSource: { filter: { a: 1 } } }], + ['an adapter-shaped `dataSource`', { type: 'object-grid', objectName: 'task', dataSource: 'objectstack' }], + ]; + + it.each(WRONG)('refuses %s', (_label, doc) => { + expect(issuesOf(AnyComponentSchema, doc)).not.toBeNull(); + expect(issuesOf(StrictAnyComponentSchema, doc)).not.toBeNull(); + }); +}); + +/* ── 3. the read keys this card did NOT declare stay refused ─────────────── */ + +describe('objectui#11070 — the read keys left undeclared pending a ruling stay refused on the strict face', () => { + // Each is read by a widget, and each waits on a question objectui#11070 + // records rather than a declaration: a snake_case second spelling of a spec + // key (`return_type` / `returnType`, `summary_type` / `summaryOperations`, + // `reference_to` / `reference`, `min_length` / `minLength`), or an element + // shape not yet decided (the grid field's `columns`). ⛔ Declaring one is a + // contract ruling, not a fix to this list. + const PENDING: ReadonlyArray]> = [ + ['return_type', { type: 'formula', return_type: 'number' }], + ['summary_type', { type: 'summary', summary_type: 'sum' }], + ['reference_to', { type: 'lookup', reference_to: 'users' }], + ['min_length', { type: 'password', min_length: 8 }], + ['columns', { type: 'grid', columns: [{ name: 'qty', type: 'number' }] }], + ]; + + it.each(PENDING)('`fields[].%s` is refused by name, and the tolerant face still accepts the document', (key, field) => { + expect(undeclared(issuesOf(StrictAnyComponentSchema, form(field)))).toEqual([`fields.0.${key}`]); + expect(issuesOf(AnyComponentSchema, form(field))).toBeNull(); + }); +}); + +/* ── 4. type level: the TypeScript faces declare the same members ────────── */ + +type Expect = T; +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +/** Each declared member is a named, typed member — not the index signature's `any`. */ +export type assertionFormFieldMembersAreTyped = Expect, + false +>>; +export type assertionShowSubmitIsBoolean = Expect>; +export type assertionBindingIsTyped = Expect, + false +>>; + +// @ts-expect-error — an adapter is not a binding: the binding names an `object`. +export const adapterIsNotABinding: ObjectGridSchema['dataSource'] = { find: () => [] }; +export const bindingOnListView: ListViewSchema['dataSource'] = { object: 'task', view: 'open', limit: 20 }; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index ad8234b1d8..c1d770e031 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -2574,7 +2574,11 @@ 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 (3 entries, 37 keys)** — it was 2 / 16 until objectui#10993 MIRRORED + * - **SPEC-DERIVED (4 entries, 38 keys)** — it was 3 / 37 until objectui#11070 declared eight + * field-metadata members on `FormFieldSchema` by reference to the spec's `FieldSchema`: + * none of them was in the ledger, so no key closed, but the spec reference re-derives + * the pair's entry (its one key, `field`) into this half — the objectui#10993 membership + * move again, with ZERO keys repaired. It was 2 / 16 until objectui#10993 MIRRORED * five `ObjectFormSchema` members by reference to the spec's `I18nLabelSchema`: none * of them was in the ledger, so no key closed, but the spec reference re-derives the * pair's entry (21 keys) into this half, the objectui#7762 membership move with ZERO @@ -2641,7 +2645,9 @@ 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 (9 entries, 47 keys)** — plain omissions from a hand-written mirror. + * - **LOCAL (8 entries, 46 keys)** — plain omissions from a hand-written mirror. + * It was 9 / 47 until objectui#11070 re-derived `FormFieldSchema`'s entry (one key) + * into the SPEC-DERIVED half by membership: no key moved between ledgers. * It was 10 / 68 until objectui#10993 re-derived `ObjectFormSchema`'s entry (21 * keys) into the SPEC-DERIVED half by membership: no key moved between ledgers. * It was 11 / 69 until objectui#8072 MIRRORED `InputSchema.wrapperClass`: the entry's @@ -2694,10 +2700,11 @@ interface KnownDrift { * `ObjectViewSchema.listViews`, that entry's last key, by reference: the SPEC-DERIVED * half lost the entry, and the LOCAL half did not move. objectui#10993 then moved * `ObjectFormSchema`'s entry (21 keys) from the LOCAL half to the SPEC-DERIVED one by - * membership alone, without moving the totals. The seeded pair is no longer + * membership alone, without moving the totals, and objectui#11070 moved + * `FormFieldSchema`'s entry (one key) the same way. 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 — - * **12 entries / 84 keys** — 3 / 37 spec-derived, 9 / 47 local. + * **12 entries / 84 keys** — 4 / 38 spec-derived, 8 / 46 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` @@ -2785,7 +2792,13 @@ interface UnmirroredDeclared { | 'page' | 'rowActionDefs' | 'rowClassName' | 'rowCount' | 'rowStyle' | 'search' | 'selectionResetKey' | 'selectionStyle' | 'showAddRow' | 'showSelectionCount' | 'singleClickEdit' | 'sort'; - /** LOCAL. */ + /** + * SPEC-DERIVED by MEMBERSHIP since objectui#11070, LOCAL before it: that card declared + * eight field-metadata members by reference to the spec's `FieldSchema`, so + * `SPEC_DERIVED_PAIRS` re-derives the pair into that half. ⚠️ `field` itself did not + * move and is not a spec key here: it is the resolved object-field metadata stash, + * kept out of the authorable surface on purpose (objectui#6609). + */ 'form.zod.ts#FormFieldSchema': 'field'; /** * LOCAL. `fields` is in `KnownDrift` above (mirrored, drifted in TYPE; `mode` sat @@ -4263,6 +4276,12 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // reference (the axis config object `ChartConfigSchema` declares), so a spec // bump that moves the axis vocabulary moves ONE side of this pair. 'data-display.zod.ts#ChartSchema', + // objectui#11070: eight of the field-metadata members a hand-authored form + // writes on the entry itself are the spec's `FieldSchema` members by + // reference (`multiple`, `rows`, `accept`, `dimensions`, `min`, `max`, + // `minLength`, `maxLength`), so a spec bump that moves one of those field + // keys moves ONE side of this pair. The first spec reference in this mirror. + 'form.zod.ts#FormFieldSchema', 'form.zod.ts#SelectOptionSchema', 'layout.zod.ts#PageNodeSchema', // ⭐ ONE entry, SIX spec crossings over FIVE spec symbols — three cards put @@ -4307,6 +4326,12 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // `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', + // objectui#11070: `dataSource` is the spec's `ElementDataSourceSchema` by + // reference (the per-element binding `PageComponentSchema.dataSource` + // declares) — the first spec reference in this mirror. `ObjectGridSchema`, + // `ObjectFormSchema` and `ListViewSchema` gained the same member and were + // already spec-derived. + 'objectql.zod.ts#ObjectKanbanSchema', '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/form.ts b/packages/types/src/form.ts index e38fc37764..08826feeec 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -17,6 +17,9 @@ import type { BaseSchema, SchemaNode } from './base.js'; import type { SelectOptionBase } from './select-option.js'; +// objectui#11070 — the field-metadata members `FormField` declares by +// reference to the spec's `FieldSchema`. Type-only: no runtime edge. +import type { Field as SpecField } from '@objectstack/spec/data'; /** * Button component @@ -1916,6 +1919,86 @@ export interface FormField { * non-divider row the key has no meaning and is ignored by the renderer. */ fields?: string[]; + + // ── Field metadata written on the entry itself (objectui#11070) ───────── + // + // A hand-authored `form` has no object schema behind it, so nothing is + // stashed on `field` above: the renderer hands each field widget the ENTRY + // ITSELF as its metadata carrier (`field: field.field || field`, the one + // `renderFieldComponent` call in `renderers/form/form.tsx`), and the + // built-in `input` / `textarea` branches spread the entry's remaining keys + // onto the native control. The members below are the keys that path reads + // off the entry. Each one `@objectstack/spec` declares on `FieldSchema` is + // typed BY REFERENCE to that member, so the two cannot drift; `pattern` is + // the one the spec does not declare. + // + // ⛔ Not every key a widget reads off the carrier is declared here. Four are + // read in a snake_case spelling beside a spec key of the same meaning — + // `return_type` (spec `returnType`), `summary_type` (spec + // `summaryOperations`), `reference_to` (spec `reference`) and `min_length` + // (spec `minLength`) — so declaring one would add a second spelling to this + // contract, which is a ruling to make, not a declaration to write. The grid + // field's `columns` is read too, but its element shape is undecided: the + // declared `GridColumnDefinition` (`./field-types.ts`) is not the shape + // `GridField` reads. objectui#11070 carries all five as open questions. + + /** + * Hold several values instead of one. Read by the `file`, `image`, + * `lookup` and `user` field widgets off their metadata carrier: the upload + * widgets put `multiple` on their file ``, the pickers switch to + * multi-value selection. The built-in `select` branch does not read it. + */ + multiple?: SpecField['multiple']; + /** + * Height of the inline editor, in text rows. The built-in `textarea` branch + * spreads it onto the `