diff --git a/.changeset/6357-basechema-bind-declaration.md b/.changeset/6357-basechema-bind-declaration.md new file mode 100644 index 0000000000..c2d8302b83 --- /dev/null +++ b/.changeset/6357-basechema-bind-declaration.md @@ -0,0 +1,44 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-dashboard': patch +--- + +`BaseSchema` declares `bind`, the data-scope binding path, on both halves — the TypeScript +interface and its Zod mirror (objectui#6357). + +`bind` was read by ten production sites and declared by no schema shape. It resolved as `any` +through `BaseSchema`'s index signature and rode `.passthrough()` on the validator, while three +separate documents taught it as an authorable key of *every* node: this repo's own `AGENTS.md` +§4 ("Every node in the UI tree follows this shape (`@object-ui/types`)"), the published +agent-facing `skills/objectui/rules/protocol.md` ("Every UI component node MUST follow this +shape"), and `content/docs/fields/grid.mdx`. So the agent-facing protocol told authors to write +a key the published types did not know existed. + +The census chose the home rather than guessing it. Nine reads go through +`useDataScope(schema.bind)` — `list` and `tree-view` in `@object-ui/components`, and the +`object-*` widgets in `plugin-charts`, `plugin-dashboard` (×2), `plugin-grid`, `plugin-kanban`, +`plugin-list`, `plugin-timeline`. A tenth is `plugin-grid`'s `gridNeedsDataSource` predicate, +where a present `bind` is one of the escape hatches that makes a missing data-source adapter +legitimate. Two more sites destructure the key out so `SchemaRenderer`'s prop spread cannot +write `bind="data.revenue"` onto the DOM. Per-component declaration was measured and rejected: +it costs nine copies of one key and buys nothing extra, because neither half can refuse the key +on a non-reader either way. `placeholder` is the standing precedent for a cross-cutting key +declared on `BaseSchema` and honoured only by a subset. + +**Accept-set narrowing, on the value and not the key.** `bind: 42` type-checked and parsed green +before this change and is refused by both halves now. It only refuses what already crashed: +`useDataScope` is `(path?: string)` and resolves via `path.split('.')`, so a non-string `bind` +threw a `TypeError` at render time. Every `bind` authored in this repo is a string, and the +declaration is optional, so nothing that renders today stops. + +**What this does NOT change**, stated because the pin would otherwise be read as more than it is: +an *undeclared* key is still accepted by both halves, so this did not buy rejection of a +misspelling such as `bindTo` (objectui#5155 / objectui#6269 own that ceiling). And `data-table` +still does not call `useDataScope`, so a `bind` on it is still ignored and still renders a header +over an empty body with no error — a documented silent failure that this declaration neither +causes nor cures, since the key was accepted on every node before it existed. + +`ObjectPivotTable` drops its local `bind?: string`: its `PivotTableSchema & {…}` intersection +extends `BaseSchema`, so the member was a true duplicate. Two other local declarations are left +in place and ratcheted rather than removed — their containing types never reference `BaseSchema`, +so deleting the member would delete the declaration rather than inherit it. diff --git a/packages/plugin-dashboard/src/ObjectPivotTable.tsx b/packages/plugin-dashboard/src/ObjectPivotTable.tsx index b7688fd424..c759ad7ba0 100644 --- a/packages/plugin-dashboard/src/ObjectPivotTable.tsx +++ b/packages/plugin-dashboard/src/ObjectPivotTable.tsx @@ -42,7 +42,10 @@ export interface ObjectPivotTableProps { schema: PivotTableSchema & { objectName?: string; dataProvider?: { provider: string; object?: string }; - bind?: string; + // The data-scope binding key is NOT re-declared here. It used to be, as a + // local member grown because no schema shape declared it — the + // second-declaration class objectui#6357 measured. `PivotTableSchema + // extends BaseSchema`, which now declares it once, same spelling. filter?: any; }; dataSource?: any; diff --git a/packages/types/src/__tests__/base-bind-declared.test.ts b/packages/types/src/__tests__/base-bind-declared.test.ts new file mode 100644 index 0000000000..d563bffc3e --- /dev/null +++ b/packages/types/src/__tests__/base-bind-declared.test.ts @@ -0,0 +1,264 @@ +/** + * 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. + */ + +/** + * Declaration pin — `bind`, the data-scope vocabulary read across the repo and + * declared by no schema shape (objectui#6357). + * + * ## What was wrong + * + * Ten production sites read `bind` off a schema node and NOTHING declared it. + * It resolved as `any` through `BaseSchema`'s index signature, while three + * separate documents taught it as an authorable key of every node — this + * repo's own `AGENTS.md` §4 ("Every node in the UI tree follows this shape + * (`@object-ui/types`)", declaring `bind` as an optional string), the PUBLISHED agent-facing + * `skills/objectui/rules/protocol.md` ("Every UI component node MUST follow + * this shape"), and `content/docs/fields/grid.mdx`. + * + * The census that chose this home, measured on `origin/main` `c5037fd29`: + * + * - **9** reads of `useDataScope(schema.bind)` — `components`' `list` and + * `tree-view`, and the `object-*` widgets in `plugin-charts`, + * `plugin-dashboard` (×2), `plugin-grid`, `plugin-kanban`, `plugin-list`, + * `plugin-timeline`; + * - **1** non-hook read — `plugin-grid/src/index.tsx`'s `gridNeedsDataSource` + * predicate, where `schema?.bind != null` is one of the escape hatches that + * makes a missing data-source adapter legitimate rather than a defect; + * - **2** DOM-strip destructures — `MetricWidget` / `MetricCard`, which + * destructure `bind` out so `SchemaRenderer`'s spread cannot write + * `bind="data.revenue"` onto the DOM (objectui#4357). + * + * ## Why `BaseSchema` and not nine per-component declarations + * + * Because per-component buys NOTHING extra — see the ceiling below, which is + * symmetric: neither half can refuse the key on a non-reader either way. It + * costs nine copies of one key for zero enforcement, and the class had already + * generated FOUR local declarations before this one existed — three spelled + * `string`, one spelled `unknown`. `schemaHostProps.ts`'s own header names the + * hazard: "two copies of one key list is how a list becomes two disagreeing + * lists". Exactly ONE of the four was a true duplicate of a base member — + * `ObjectPivotTable`'s, whose `PivotTableSchema & {…}` intersection does extend + * `BaseSchema`; it is removed by this card. The other three are load-bearing: + * their containing types never reference `BaseSchema`, so deleting the member + * deletes the declaration rather than inheriting it. They are ratcheted below. + * + * `placeholder` is the standing precedent for a cross-cutting key declared here + * and honoured by a subset: every node may write it, only inputs read it. + * + * ## The ceiling, stated rather than assumed (objectui#5155 / objectui#6269) + * + * Same ceiling as objectui#5903's gantt pin and objectui#6170's timeline pin. + * `BaseSchema` carries `[key: string]: any` on the TS side and is + * `.passthrough()` on the zod side, so: + * + * - an UNDECLARED key is still accepted by both halves. Declaring `bind` did + * NOT buy rejection of `bindTo`, and the counter-probe below pins that + * honestly rather than letting a reader assume otherwise; + * - a DECLARED key IS validated. `bind: 42` type-checked and parsed green + * before this card and is refused by both halves now — the accept-set + * narrowing this card lands; + * - on the TS side a read site can never be the detector, because the index + * signature types `schema.bind` as `any` either way. So the compile-time + * pin is the `@ts-expect-error` block at the bottom: remove the + * declaration and the member resolves to `any`, the wrong-typed assignment + * starts succeeding, and the now-unused directive fails the build (TS2578) + * NAMING the key. `tsconfig.test.json` compiles this file, so that is real + * enforcement and not decoration (objectui#3009). + * + * The narrowing only refuses what already crashed: `useDataScope` is + * `(path?: string)` and resolves via `path.split('.')`, so a non-string `bind` + * threw a TypeError at render time. + * + * ## What this pin deliberately does NOT cover + * + * `data-table` does not call `useDataScope`, so a `bind` on it is ignored and + * the table renders its header over an empty body — no error, no warning. That + * is recorded in `protocol.md` and already pinned in + * `components/src/__tests__/skill-guide-data-table-binding.test.tsx`. Declaring + * the key here neither causes nor cures it: `bind` was accepted on every node + * before this declaration existed, via the index signature and `.passthrough()`. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { BaseSchema } from '../zod/base.zod.js'; +import type { BaseSchema as BaseSchemaTS } from '../base.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); + +const MINIMAL = { type: 'list' } as const; + +describe('BaseSchema (zod) — `bind` is mirrored and validated', () => { + it('declares `bind` in the mirror shape', () => { + // The pair `base.zod.ts#BaseSchema` carries no `KnownDrift` / + // `UnmirroredDeclared` entry, so `zod-mirror-parity.test.ts` independently + // reddens by name if the TS declaration ever outruns this member. This + // asserts the member directly so the failure is readable here too. + expect(Object.keys(BaseSchema.shape)).toContain('bind'); + }); + + it('accepts the path forms the readers resolve', () => { + for (const bind of ['customerNames', 'app.settings.users', 'rows']) { + expect(BaseSchema.safeParse({ ...MINIMAL, bind }).success, bind).toBe(true); + } + }); + + it('refuses a non-string `bind` — the accept-set narrowing this card lands', () => { + // Green before this card (`.passthrough()` waved it through); it then threw + // `path.split is not a function` inside `useDataScope` at render time. + for (const bind of [42, true, { path: 'customers' }, ['customers']]) { + expect(BaseSchema.safeParse({ ...MINIMAL, bind }).success, JSON.stringify(bind)).toBe(false); + } + }); + + it('still accepts a MISSPELLING — the ceiling, pinned honestly', () => { + // Counter-probe against reading the two assertions above as more than they + // are. `.passthrough()` accepts any undeclared key, so `bindTo` is waved + // through exactly as `bind` used to be. Closing THAT is objectui#5155 / + // objectui#6269, not this card; if it is ever closed, this expectation is + // the one that must be revisited deliberately rather than silently. + expect(BaseSchema.safeParse({ ...MINIMAL, bindTo: 'customers' }).success).toBe(true); + }); + + it('leaves `bind` optional — every node that never binds still parses', () => { + expect(BaseSchema.safeParse(MINIMAL).success).toBe(true); + }); +}); + +describe('BaseSchema (TS) — compile-time pin on `bind`', () => { + it('refuses a wrong-typed `bind`', () => { + // This directive fails the build (TS2578, "unused '@ts-expect-error'") the + // moment `bind` stops being declared, because the member then resolves to + // `any` through the index signature and the assignment starts succeeding. + // That failure is the signal this card exists to create. + + // @ts-expect-error — `bind` is declared `string | undefined`. + const bind: BaseSchemaTS['bind'] = 42; + + expect(bind).toBe(42); + }); + + it('accepts a well-typed `bind`', () => { + // Counter-probe for the directive above: without this, a declaration + // narrowed to `never` would satisfy it. + const node: BaseSchemaTS = { type: 'list', bind: 'customerNames' }; + expect(node.bind).toBe('customerNames'); + }); +}); + +/** + * ONE declaration, not N. + * + * The card's own warning is that guessing the home "would produce the second + * declaration this class keeps generating" — and two had already appeared + * before anyone declared the key centrally. This scan is the guard against the + * third: a schema-side optional `bind` member anywhere outside its home reads as a + * local re-declaration, which is how the two disagreeing spellings (`string` + * vs `unknown`) came to exist in the first place. + */ +describe('`bind` is declared in exactly one place (objectui#6357)', () => { + /** + * Every allowed non-home declaration, WITH ITS REASON. + * + * None of the three is a schema shape, which is why none of them inherits the + * declaration and none could simply be deleted. Two are hand-rolled inline + * `schema` prop types with no `BaseSchema` in their ancestry — a real defect, + * but objectui#5155 / objectui#6269's, filed and not fixed here. The third is + * a DOM-strip props type. An entry here is a declared decision; a file that is + * in neither this map nor the home fails the scan. + */ + const ALLOWED = new Map([ + [ + 'packages/plugin-dashboard/src/schemaHostProps.ts', + 'DOM-strip props type, not a schema shape — all seven members are `unknown` by design, ' + + 'because the type exists to be destructured out and never read (objectui#4357).', + ], + [ + 'packages/plugin-dashboard/src/ObjectDataTable.tsx', + 'RATCHET, not an endorsement. `ObjectDataTableProps.schema` is an inline object type that ' + + 'never references `BaseSchema`, so this member is load-bearing rather than duplicated — ' + + 'removing it would not inherit the declaration, it would delete it. The disconnected ' + + 'hand-rolled schema prop type is objectui#5155 / objectui#6269 territory, not this card.', + ], + [ + 'packages/plugin-list/src/ObjectGallery.tsx', + 'RATCHET, same shape as the entry above — `ObjectGalleryProps.schema` is a hand-rolled inline ' + + 'type with no `BaseSchema` in its ancestry and (unlike that one) no index signature, so ' + + 'dropping the member is a compile error rather than an inheritance.', + ], + ]); + + const HOME = 'packages/types/src/base.ts'; + + it('no schema shape re-declares `bind` outside `BaseSchema`', async () => { + const { execFileSync } = await import('node:child_process'); + // `git grep` over TRACKED files only, so an untracked scratch file or a + // stray build artefact cannot fail this. `-n` for a readable failure. + // ASSEMBLED, never written as one literal. `git grep` searches TRACKED + // files, so the moment this file is committed a literal pattern would match + // THIS file and the scan would report itself. Allow-listing itself would + // have been the wrong repair — it puts a permanent hole in the scan at the + // one path most likely to grow a copy of the pattern. + const PATTERN = 'bind' + '?:'; + + let out: string; + try { + out = execFileSync( + 'git', + ['grep', '-n', '-F', '--', PATTERN, 'packages'], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ); + } catch (err: any) { + // `git grep` exits 1 on "no matches" — which would mean the home + // declaration itself vanished. Fall through to the assertions below, + // which then fail naming it. + out = err?.stdout ?? ''; + } + + const hits = out + .split('\n') + .filter(Boolean) + .map((line) => line.slice(0, line.indexOf(':'))); + + // Counter-probe: a filter over an empty scan passes vacuously. + expect(hits, 'the scan found nothing at all — check the pattern').not.toHaveLength(0); + expect(hits, `\`${HOME}\` must declare \`bind\``).toContain(HOME); + + const strays = [...new Set(hits)].filter((f) => f !== HOME && !ALLOWED.has(f)); + expect( + strays, + `a second \`${PATTERN}\` declaration appeared — declare it once on \`BaseSchema\`, ` + + 'or add the file to ALLOWED above with its reason', + ).toEqual([]); + }); + + it('the readers still read the key this declaration is about', () => { + // The declaration is only worth its doc comment while the reads exist. One + // representative per package, so a rename that leaves the key undeclared + // again cannot pass quietly. + const readers = [ + 'packages/components/src/renderers/data-display/list.tsx', + 'packages/components/src/renderers/data-display/tree-view.tsx', + 'packages/plugin-charts/src/ObjectChart.tsx', + 'packages/plugin-dashboard/src/ObjectDataTable.tsx', + 'packages/plugin-dashboard/src/ObjectPivotTable.tsx', + 'packages/plugin-grid/src/ObjectGrid.tsx', + 'packages/plugin-kanban/src/ObjectKanban.tsx', + 'packages/plugin-list/src/ObjectGallery.tsx', + 'packages/plugin-timeline/src/ObjectTimeline.tsx', + ]; + for (const rel of readers) { + const src = readFileSync(join(REPO_ROOT, rel), 'utf8'); + expect(src, `${rel} no longer reads \`schema.bind\``).toContain('useDataScope(schema.bind)'); + } + expect(readers).toHaveLength(9); + }); +}); 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 e405e4a1c5..b87b4bd67e 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 @@ -24,6 +24,13 @@ * ObjectFormSchema -> 67 members * Omit -> 0 members * + * ⚠️ 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`, + * 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. + * * 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 @@ -173,18 +180,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 61 members and carries the #5155 index signature', () => { + it('ObjectGridSchema declares 62 members and carries the #5155 index signature', () => { const grid = exportedType('ObjectGridSchema'); - expect(memberNames(grid)).toHaveLength(61); + expect(memberNames(grid)).toHaveLength(62); 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', () => { + it('ObjectFormSchema declares 68 members and carries the #5155 index signature', () => { const form = exportedType('ObjectFormSchema'); - expect(memberNames(form)).toHaveLength(67); + expect(memberNames(form)).toHaveLength(68); expect(memberNames(form)).toEqual(expect.arrayContaining(['fields', 'sections', 'submitText'])); expect(declaresStringIndex(form)).toBe(true); }); diff --git a/packages/types/src/__tests__/object-view-spec-parity.test.ts b/packages/types/src/__tests__/object-view-spec-parity.test.ts index 1af529cd49..f28324c3c5 100644 --- a/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -145,7 +145,7 @@ const ENVELOPE = new Set(shapeKeys(OuiBaseSchema)); * Envelope keys this node REDECLARES with a narrower type. * * Detected by member IDENTITY against `BaseSchema`, not by a hand-written list: - * `BaseSchema.extend()` copies the envelope's 20 members in, so a key that is + * `BaseSchema.extend()` copies the envelope's 21 members in, so a key that is * still the base's own member is inherited, and one that is a different object * was deliberately narrowed on this node (`type: z.literal('object-view')`, * `description: z.string()`). They are part of the node's declared surface and diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 38797ec5b9..74bfdd2695 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -192,7 +192,7 @@ export type NarrowerThanDeclared< M, D > = { * index signatures SEPARATELY, so remapping the index-signature keys to `never` * leaves the literal members — INCLUDING the ones inherited from `BaseSchema`. * Same probe against this alias resolves the 36 literal names of - * `ObjectGanttSchema` and the 20 of `BaseSchema`. + * `ObjectGanttSchema` and the 21 of `BaseSchema` (20 until objectui#6357 added `bind`). * * ⚠️ This lifts the ceiling on what the GUARD can READ, not on what a mirror can * REJECT. #5155's ceiling stands: `BaseSchema` is `.passthrough()`, so declaring a diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index c06cc8c458..bf5ca5162e 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -182,6 +182,70 @@ export interface BaseSchema { */ data?: any; + /** + * Data-scope path this node draws its rows/value from — the SDUI data-binding + * vocabulary, resolved by `useDataScope()` (`@object-ui/react`). + * + * ```json + * { "type": "list", "bind": "customerNames" } // → dataSource.customerNames + * { "type": "object-kanban", "bind": "app.settings.users" } + * ``` + * + * ## Why it is declared HERE and not on each reader (objectui#6357) + * + * `bind` was read by ten production sites and declared by NO schema shape — + * it rode `BaseSchema`'s index signature as `any`, while three separate + * documents taught it as an authorable key of EVERY node: this repo's own + * `AGENTS.md` §4 ("Every node in the UI tree follows this shape + * (`@object-ui/types`)" — `bind?: string`), the published agent-facing + * `skills/objectui/rules/protocol.md` ("Every UI component node MUST follow + * this shape"), and `content/docs/fields/grid.mdx`. + * + * Per-component declaration was measured and rejected: it costs nine copies + * of one key and buys NOTHING extra, because neither half can refuse the key + * on a non-reader either way (see the ceiling below). The class had already + * generated FOUR local declarations before this one existed — three spelled + * `string`, one spelled `unknown` — which is exactly the "two copies of one + * key list is how a list becomes two disagreeing lists" hazard that + * `plugin-dashboard/src/schemaHostProps.ts`'s own header warns about. Only + * one of the four was a true duplicate of a base member (`ObjectPivotTable`'s, + * removed with this card); the other three are load-bearing because their + * containing types never reference `BaseSchema` at all, and that disconnection + * is objectui#5155 / objectui#6269's defect, not this card's. They are held as + * a ratchet in `__tests__/base-bind-declared.test.ts`. + * + * Precedent for a cross-cutting key honoured by a SUBSET living here: + * {@link BaseSchema.placeholder}, declared for every node and read only by + * input components. + * + * ## Readers, and the one documented silent failure + * + * Only a component that calls `useDataScope` honours it. Measured readers: + * `list` and `tree-view` (`@object-ui/components`), and the `object-*` + * widgets in `plugin-charts` / `plugin-dashboard` (×2) / `plugin-grid` / + * `plugin-kanban` / `plugin-list` / `plugin-timeline`. ⚠️ `data-table` does + * NOT: a `bind` on it is ignored and the table renders its header over an + * empty body, with no error and no warning (`protocol.md`, and pinned in + * `components/src/__tests__/skill-guide-data-table-binding.test.tsx`). + * Declaring the key here does not change that, and does not bless it — the + * key was already accepted on every node before this declaration existed. + * + * ## What declaring it buys, and what it does not (objectui#5155 / #6269) + * + * Same ceiling as the gantt and timeline pins. `BaseSchema` carries an index + * signature on the TS side and is `.passthrough()` on the zod side, so an + * UNDECLARED key is still accepted by both halves — this did NOT buy + * rejection of a misspelling such as `bindTo`. What it DOES buy is the VALUE: + * `bind: 42` type-checked and parsed green before this declaration and is + * refused by both halves now. That narrowing only refuses what already + * crashed — `useDataScope` is `(path?: string)` and resolves via + * `path.split('.')`, so a non-string `bind` threw a TypeError at render. + * + * @example "customerNames" + * @example "app.settings.users" + */ + bind?: string; + /** * Child components or content. * Can be a single component, array of components, or primitive values. diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 6c82feaf47..bf8479a893 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1559,6 +1559,7 @@ type ObjectGridSlotKey = | 'aggregations' | 'ariaLabel' | 'batchActions' + | 'bind' | 'body' | 'bulkActionDefs' | 'bulkActions' @@ -1629,6 +1630,7 @@ type ObjectGridSlotKey = type ObjectFormSlotKey = | 'allowSkip' | 'ariaLabel' + | 'bind' | 'body' | 'buttons' | 'cancelText' diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index 7773c28230..a648f06926 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -119,6 +119,20 @@ const BaseSchemaCore = z.object({ */ data: z.any().optional().describe('Custom data payload'), + /** + * Data-scope path, resolved by `useDataScope()`. + * + * Mirrors `BaseSchema.bind: string` (`../base.ts`, objectui#6357). The pair + * `base.zod.ts#BaseSchema` carries no `KnownDrift` / `UnmirroredDeclared` + * entry, so this member is not optional housekeeping: a key declared on the + * TS side and missing here reddens `zod-mirror-parity.test.ts` by name. + * + * `z.string()` and not `z.any()` because that is what the resolver accepts — + * `useDataScope` is `(path?: string)` and resolves via `path.split('.')`, so + * a non-string value threw at render time and this rejects it at parse time. + */ + bind: z.string().optional().describe('Data-scope binding path (resolved by useDataScope)'), + /** * Child components or content */