diff --git a/.changeset/23447-page-component-view-data-judge-deleted.md b/.changeset/23447-page-component-view-data-judge-deleted.md new file mode 100644 index 00000000000..4b84c651467 --- /dev/null +++ b/.changeset/23447-page-component-view-data-judge-deleted.md @@ -0,0 +1,34 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `checkPageComponentViewData` is deleted; a page component's `data` prop naming the retired view-level `api` source is refused by the component-props rule alone, at the CLI doors and the metadata save door (#23447) + +**BREAKING** — `checkPageComponentViewData` is no longer exported from `@objectstack/spec/ui`, and `PageSchema` no longer carries it. Since the component-props rule (`validateComponentProps`, `@objectstack/lint`) runs on a `page` write at the metadata save door, it and the page-parse check judged the same binding at the same door: one operation with two implementations. The governed side stays. The props rule parses the component's `properties` against the type's `ComponentPropsMap` row, and that row's `data` is `ViewDataSchema`, so it raises the retirement's own prescription on the same four types (`object-grid`, `object-map`, `object-gantt`, `object-tree`), at the same positions: regions, `page:tabs` items, nested containers and slots. + +Clause-②: yes (narrowing) + +What narrows: the published export leaves `@objectstack/spec/ui`. What `PageSchema` accepts widens by this one shape, a component `data` naming the retired provider, which the props rule then refuses at every door that runs it. + +## FROM → TO + +| you wrote | write instead | +| --- | --- | +| `import { checkPageComponentViewData } from '@objectstack/spec/ui'`, and `.superRefine(checkPageComponentViewData)` on a mirror built from `PageSchema.shape` | nothing on the mirror: a page component's `properties` is judged by `validateComponentProps` from `@objectstack/lint` (or `runAuthoringRules`), against the stack that carries the page | +| a page component's `properties.data` with `provider: 'api'` | unchanged from the step-18 retirement: `provider: 'object'` with `object`, or `provider: 'value'` with `items`; an endpoint read moves onto the component's `dataSource: { provider: 'api', read: { url: '/api/...' } }` | + +**The one-line fix:** drop the import and the re-attachment; run the props rule where you need the verdict. + +**Which doors refuse the binding now, measured:** + +| door | before | after | +| --- | --- | --- | +| `definePage()`, `defineStack({ pages })`, `PageSchema.safeParse` | refused, `custom` at `…properties.data.provider` | accepted (these doors run no lint rule) | +| `os validate`, `os build` | refused inside `defineStack` | refused by the props rule: `component-props-invalid` at `pages[0].regions[0].components[0].properties.data.provider`, with the prescription | +| metadata save door (`PUT /api/v1/meta/page/:name`, the Studio, MCP) | `422 INVALID_METADATA`, spec-parse issue `custom` | `422 INVALID_METADATA`, rule `component-props-invalid` at the same component, with the prescription; nothing stored | +| boot (`loadMetaFromDb`) | `invalid: 1`, registered under `[metadata_spec_invalid]` | `invalid: 0`, registered, no warning | +| read (`getMetaItem`) | served, `_diagnostics.valid: false` with the refusal | served as stored, `_diagnostics.valid: true` | + +**A page already stored with such a component** still loads and is served as stored, and no longer carries a read diagnostic for the binding; its next save is refused with 422 by the props rule. `os migrate meta --stored` still lists it under the step-18 entry `view-data-api-provider-retired`, and that entry's fate text now says so. + + diff --git a/.changeset/pre/23357-page-component-view-data-api-refused.md b/.changeset/pre/23357-page-component-view-data-api-refused.md index fadcedc8fe7..87383e07f9b 100644 --- a/.changeset/pre/23357-page-component-view-data-api-refused.md +++ b/.changeset/pre/23357-page-component-view-data-api-refused.md @@ -2,25 +2,25 @@ '@objectstack/spec': minor --- -feat(spec)!: the page parse refuses a page component's `data` prop that names the retired view-level `api` data source, so the Studio, REST and MCP save door refuses it as `os validate` does (#23357) +feat(spec)!: a page component's `data` prop that names the retired view-level `api` data source is refused at the Studio, REST and MCP save door as `os validate` refuses it (#23357) -**BREAKING** — a page whose `object-grid`, `object-map`, `object-gantt` or `object-tree` component carries `properties.data` with `provider: 'api'` is now refused by `PageSchema`, at the component's `properties.data.provider`, with the retirement's own prescription. Those four components take a view data source (`ViewDataSchema`) as `data`, so the step-18 retirement of the view-level `api` source reached them through the same schema; but the page parse leaves a component's `properties` an open record, and the component-props gate that refused the binding runs at the CLI doors only. A page saved through the Studio, REST or MCP therefore kept the binding, with its request headers, and the console kept sending it. +**BREAKING** — a page whose `object-grid`, `object-map`, `object-gantt` or `object-tree` component carries `properties.data` with `provider: 'api'` is refused at the component's `properties.data.provider`, with the retirement's own prescription, by the component-props gate (`validateComponentProps`, `@objectstack/lint`, rule `component-props-invalid`) at `os validate`, `os build`, `os lint` and the metadata save door. Those four components take a view data source (`ViewDataSchema`) as `data`, so the step-18 retirement of the view-level `api` source reached them through the same schema. The page parse leaves a component's `properties` an open record, and before 18 the component-props gate ran at the CLI doors only, so a page saved through the Studio, REST or MCP kept the binding, with its request headers, and the console kept sending it. Clause-②: yes (narrowing) -The judge is the retirement's own: `ViewDataSchema` parses the component's `data`, and the prescription it raises is raised by the page parse unchanged (one text for a view and a page; the page's issue `code` is `custom`, as every object-level check on `PageSchema` raises). It reaches every component under `regions` and `slots`, at any depth, a component nested in a `page:tabs` item or a container included. It judges nothing else in the bag: another `data` provider, a component whose `data` is a row array (`object-kanban`, `object-calendar`, `object-timeline`), a custom or registered type with no props row, and a component's own `dataSource` binding are untouched. `checkPageComponentViewData` is exported from `@objectstack/spec/ui`, like the four other `PageSchema` checks, so a mirror built from `PageSchema.shape` can re-attach it. +The judge is the retirement's own: the component's `ComponentPropsMap` row parses its `data` with `ViewDataSchema`, and the finding carries that schema's prescription unchanged, one text for a view and a page. It reaches every component under `regions` and `slots`, at any depth, a component nested in a `page:tabs` item or a container included. Only those four types meet this prescription: a component whose `data` is a row array (`object-kanban`, `object-calendar`, `object-timeline`) is judged by its own row and refused for its shape, a custom or registered type with no props row is not judged, and a component's own `dataSource` binding is untouched. ## FROM → TO -| you wrote (accepted by the page parse until this change) | write instead | +| you wrote (saved by the Studio, REST and MCP door before 18) | write instead | | --- | --- | | `properties.data` with `provider: 'api'` (and its `read` / `write` request blocks) on an `object-grid`, `object-map`, `object-gantt` or `object-tree` page component | `properties.data` with `provider: 'object'` and `object: ''`, or `provider: 'value'` with `items: [...]`; an endpoint the page must read is a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }`, same-origin and with no credential key | | `read.headers: { Authorization: '...' }` inside that binding | nothing: a credential never lives in metadata, and the console reads an element binding with its own signed-in fetch | **The one-line fix:** bind each such component to its object or to static rows, and move any endpoint read onto a page component's `dataSource`. -**What an author who still writes it sees.** `definePage()`, `defineStack({ pages })`, `os validate` and `PUT /api/v1/meta/page/:name` refuse the page at `regions[i].components[j].properties.data.provider` (or the nested or slot position) with the prescription. At the save door that is `422 INVALID_METADATA`, nothing persisted. +**What an author who still writes it sees.** `os validate`, `os build`, `os lint` and `PUT /api/v1/meta/page/:name` refuse the page at `pages[i].regions[j].components[k].properties.data.provider` (or the nested or slot position), rule `component-props-invalid`, with the prescription. At the save door that is `422 INVALID_METADATA`, nothing persisted. `definePage()` and `defineStack({ pages })` run no authoring rule and accept the page. -**A page already stored with such a component.** `os migrate meta --stored` lists it by page and component (the step-18 entry `view-data-api-provider-retired`), and nothing rewrites it: deleting `data` would make the component query its object instead. Left as stored, the page still loads and is served, registered under `[metadata_spec_invalid]` with the refusal in the `_diagnostics` of every read, and its next save is refused with 422. The console stops sending the request when its own reader of the retired arm is deleted (objectstack-ai/objectui#12325); until then a stored page keeps sending it. +**A page already stored with such a component.** `os migrate meta --stored` lists it by page and component (the step-18 entry `view-data-api-provider-retired`), and nothing rewrites it: deleting `data` would make the component query its object instead. Left as stored, the page still loads and is served as stored, with no read diagnostic for the binding, and its next save is refused with 422 by the component-props gate. The console no longer reads the retired arm (objectstack-ai/objectui#12325), so a stored page does not send the request. - + diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts index e3ed3effd34..1675a2dcaeb 100644 --- a/packages/lint/src/validate-component-props.ts +++ b/packages/lint/src/validate-component-props.ts @@ -109,11 +109,14 @@ * object arm tombstones `publicPicker`. * * Where it refuses: the three CLI doors — `os validate`, `os build`, `os lint` - * — run every gating rule. The runtime publish door (the Studio, REST and MCP - * write path) does not run this rule (`surfaces` in `authoring-rules.ts`), so a - * page saved there is not refused for any of these findings. That is a - * rollout decision, not a missing input: a `page` write's per-write snapshot - * carries the written page, which is everything this rule reads. + * — and the runtime publish door (the Studio, REST and MCP write path), on a + * `page` write (`surfaces` / `runtimeTypes` in `authoring-rules.ts`, the ruling + * of record 6161173492, letter B). A `page` write's per-write snapshot carries + * the written page, which is everything this rule reads. The boot and read + * paths run no authoring rule, so a stored page with a finding still loads and + * is served; its next save is refused. This rule is the one judge of a + * component's `properties`: the page parse leaves the bag an open record and + * carries no second judge of any prop, a retired `data` provider included. * * ## Unregistered types are SKIPPED — a required semantic, not leniency * diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 4747a670c8b..60cece3064a 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -3197,3 +3197,136 @@ describe('runtime authoring gate on PAGE writes — the component props and type expect(JSON.parse(activePageRows(rows)[0]!.metadata)).toEqual(cleanPage()); }); }); + +// A page component's `data` prop naming the retired view-level `api` provider, +// at the runtime save door. `object-grid`, `object-map`, `object-gantt` and +// `object-tree` take a view data source (`ViewDataSchema`) as `data`, so the +// step-18 retirement of that provider reaches them through their +// `ComponentPropsMap` rows. The page parse leaves `properties` an open record; +// the rule that judges the bag is `validateComponentProps`, which runs on a +// `page` write by the ruling of record above (6161173492, letter B). One +// operation, one implementation: the refusal is that rule's +// `component-props-invalid`, carrying the retirement's own prescription. +// +// Stored pages: the boot and read paths run no authoring rule, so a stored +// page with the binding loads, is registered and is served with no read +// diagnostic for it; its next save is refused until the binding is fixed. +describe('runtime authoring gate on PAGE writes — a component `data` naming the retired view-level api source', () => { + // Spelled through a constant: the spec's tree-scoped absence pin reads + // authored literals, and these bodies are refusals, not authorings. + const RETIRED_PROVIDER = 'api' as const; + const RETIRED_BINDING = { + provider: RETIRED_PROVIDER, + read: { url: 'https://partner.example.com/cases', headers: { Authorization: 'Bearer s3cr3t' } }, + }; + const PRESCRIPTION = "`view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049"; + const block = (type: string, data: unknown, id = 'cases') => ({ id, type, properties: { objectName: 'crm_case', data } }); + const page = (components: unknown[], extra: Record = {}) => ({ + name: 'partner_page', + label: 'Partner', + type: 'app', + regions: [{ name: 'main', components }], + ...extra, + }); + const savePage = (protocol: any, item: unknown) => + protocol.saveMetaItem({ type: 'page', name: 'partner_page', item }); + const activePageRows = (rows: Map) => + Array.from(rows.values()).filter((r) => r.type === 'page' && r.state === 'active'); + const retirementIssues = (err: any) => + (err?.issues ?? []).filter((i: any) => i.rule === 'component-props-invalid' && String(i.message).includes(PRESCRIPTION)); + + let warn: ReturnType; + beforeEach(() => { + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + afterEach(() => { + warn.mockRestore(); + delete process.env.OS_ALLOW_UNLINTED_METADATA_WRITES; + }); + + it.each(['object-grid', 'object-map', 'object-gantt', 'object-tree'])( + '%s: 422 INVALID_METADATA from the props rule at the component\'s `data.provider`, with the prescription; nothing stored', + async (type) => { + const { protocol, rows } = makeProtocol(); + + const err = await savePage(protocol, page([block(type, RETIRED_BINDING)])).catch((e: any) => e); + + expect(err).toBeInstanceOf(Error); + expect(err.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(err.rulesRun).toContain('validateComponentProps'); + expect(retirementIssues(err).map((i: any) => i.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.data.provider', + ]); + expect(activePageRows(rows)).toEqual([]); + }, + ); + + it('reaches a component in a `page:tabs` item, one nested in containers, and one in a slot', async () => { + const { protocol, rows } = makeProtocol(); + const body = page([ + { id: 'tabs', type: 'page:tabs', properties: { items: [{ label: 'Cases', children: [block('object-grid', RETIRED_BINDING, 'in_tab')] }] } }, + { id: 'row', type: 'flex', properties: { children: [{ id: 'card', type: 'page:card', properties: { children: [block('object-tree', RETIRED_BINDING, 'deep')] } }] } }, + ], { kind: 'slotted', type: 'record', object: 'crm_case', slots: { details: block('object-map', RETIRED_BINDING, 'slotted') } }); + + const err = await savePage(protocol, body).catch((e: any) => e); + + expect(err?.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(retirementIssues(err).map((i: any) => i.path).sort()).toEqual([ + 'pages[0].regions[0].components[0].properties.items[0].children[0].properties.data.provider', + 'pages[0].regions[0].components[1].properties.children[0].properties.children[0].properties.data.provider', + 'pages[0].slots.details.properties.data.provider', + ]); + expect(activePageRows(rows)).toEqual([]); + }); + + it('the control: the same page with the `object` or `value` provider saves, one row', async () => { + for (const data of [{ provider: 'object', object: 'crm_case' }, { provider: 'value', items: [{ name: 'a' }] }]) { + const { protocol, rows } = makeProtocol(); + await expect(savePage(protocol, page([block('object-grid', data)]))).resolves.toMatchObject({ success: true }); + expect(activePageRows(rows), JSON.stringify(data)).toHaveLength(1); + } + }); + + it('a stored page with the binding loads, registers and is served with no read diagnostic; its next save is refused until fixed', async () => { + const { engine, rows } = makeStubEngine(); + const registered: Array<{ type: string; name: unknown }> = []; + engine.registry.registerItem = (type: string, item: { name?: unknown }) => { + registered.push({ type, name: item?.name }); + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_test') as any; + const stored = page([block('object-grid', RETIRED_BINDING)]); + rows.set(keyOf({ type: 'page', name: 'partner_page', organization_id: null, state: 'active' }), { + id: 'r_seed', type: 'page', name: 'partner_page', organization_id: null, state: 'active', + metadata: JSON.stringify(stored), + }); + + // Boot: no authoring rule runs, and the page parse no longer judges the bag. + const result = await protocol.loadMetaFromDb(); + expect(result).toMatchObject({ loaded: 1, errors: 0, invalid: 0 }); + expect(registered).toContainEqual({ type: 'page', name: 'partner_page' }); + // Read: served as stored, with no diagnostic for the binding. + const served: any = (await protocol.getMetaItem({ type: 'page', name: 'partner_page' })).item; + expect(served?.regions?.[0]?.components?.[0]?.properties?.data?.provider).toBe(RETIRED_PROVIDER); + expect(served?._diagnostics?.valid).toBe(true); + expect(served?._diagnostics?.errors ?? []).toEqual([]); + // No `[metadata_spec_invalid]` registration either: that came from the page parse. + expect((warn.mock.calls as unknown[][]).map((c) => String(c[0])).filter((m) => m.includes('metadata_spec_invalid'))).toEqual([]); + + // The next save of the same body is a write, and is judged. + const err = await savePage(protocol, stored).catch((e: any) => e); + expect(err?.status).toBe(422); + expect(err.code).toBe('INVALID_METADATA'); + expect(retirementIssues(err).map((i: any) => i.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.data.provider', + ]); + expect(JSON.parse(activePageRows(rows)[0]!.metadata)).toEqual(stored); + + // Fixed, then saved. + const fixed = page([block('object-grid', { provider: 'object', object: 'crm_case' })]); + await expect(savePage(protocol, fixed)).resolves.toMatchObject({ success: true }); + expect(JSON.parse(activePageRows(rows)[0]!.metadata)).toEqual(fixed); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts index 77e7718d238..6ee6350c48f 100644 --- a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts +++ b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts @@ -491,85 +491,3 @@ describe('#20230 a flattened overlay carrying a retired owner/hidden is refused } }); }); - -/** - * #23357 — a page whose component binds the retired view-level `api` data - * source, at the save door: `saveMetaItem`, the `PUT /api/v1/meta/page/:name` - * write path the Studio, a REST caller and an MCP author reach. - * - * Before: the page parse left a component's `properties` an open record and the - * component-props gate that refuses the binding runs at the CLI doors only, so - * an `object-grid` whose `data` named the retired provider saved — one row - * persisted with its request headers — and the console kept sending it. After: - * `PageSchema`'s `checkPageComponentViewData` refuses it, and this envelope is - * what the author meets. Pinned HERE and not only in spec for the reason the - * #20230 block above gives: a spec refusal that did not reach this envelope - * would be a refusal nobody sees. - */ -describe('#23357 a page whose component binds the retired view-level api source is refused at the save door', () => { - // Spelled through a constant: the spec's tree-scoped absence pin reads - // authored literals, and these bodies are refusals, not authorings. - const RETIRED_PROVIDER = 'api' as const; - const RETIRED_BINDING = { - provider: RETIRED_PROVIDER, - read: { url: 'https://partner.example.com/cases', headers: { Authorization: 'Bearer s3cr3t' } }, - }; - const PRESCRIPTION = /^`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18 \(ADR-0049/; - const grid = (data: unknown) => ({ id: 'cases', type: 'object-grid', properties: { objectName: 'crm_case', data } }); - const pageBody = (components: unknown[]) => ({ - name: 'partner_page', - label: 'Partner', - type: 'app', - regions: [{ name: 'main', components }], - }); - const savePage = (protocol: any, body: unknown) => save(protocol, body, 'partner_page', 'page'); - - it('an `object-grid` binding it answers 422 INVALID_METADATA, persists nothing, and locates the prescription at the component', async () => { - const { protocol, rows } = makeProtocol(); - - const err = await rejection(savePage(protocol, pageBody([grid(RETIRED_BINDING)]))); - - expect(err.code).toBe('INVALID_METADATA'); - expect(err.status).toBe(422); - expect(rows.size).toBe(0); - const at = err.issues.find((i: any) => i.path === 'regions.0.components.0.properties.data.provider'); - expect(at, `an issue at the component's data provider; got ${JSON.stringify(err.issues)}`).toBeDefined(); - expect(at.code).toBe('custom'); - expect(at.message).toMatch(PRESCRIPTION); - }); - - it('a component nested in a `page:tabs` item is refused at its own position', async () => { - const { protocol, rows } = makeProtocol(); - const tabs = { id: 'tabs', type: 'page:tabs', properties: { items: [{ label: 'Cases', children: [grid(RETIRED_BINDING)] }] } }; - - const err = await rejection(savePage(protocol, pageBody([tabs]))); - - expect(err.code).toBe('INVALID_METADATA'); - expect(err.status).toBe(422); - expect(rows.size).toBe(0); - expect(err.issues.map((i: any) => i.path)).toContain('regions.0.components.0.properties.items.0.children.0.properties.data.provider'); - }); - - it('CONTROL: the same page with the `object` or `value` provider saves, one row', async () => { - for (const data of [{ provider: 'object', object: 'crm_case' }, { provider: 'value', items: [{ name: 'a' }] }]) { - const { protocol, rows } = makeProtocol(); - const result = await savePage(protocol, pageBody([grid(data)])); - expect(result.success, JSON.stringify(data)).toBe(true); - expect(rows.size).toBe(1); - } - }); - - it('READ PATH: a stored page still carrying the binding is served, with the refusal in its `_diagnostics`', async () => { - const { protocol } = makeProtocol([ - { type: 'page', name: 'partner_page', metadata: pageBody([grid(RETIRED_BINDING)]) }, - ]); - - const served = (await protocol.getMetaItem({ type: 'page', name: 'partner_page' })).item; - - expect(served.regions[0].components[0].type).toBe('object-grid'); - expect(served._diagnostics?.valid).toBe(false); - const at = served._diagnostics.errors.find((e: any) => e.path === 'regions.0.components.0.properties.data.provider'); - expect(at, JSON.stringify(served._diagnostics)).toBeDefined(); - expect(at.message).toMatch(PRESCRIPTION); - }); -}); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index f394c99c584..bd104844618 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -502,7 +502,6 @@ "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", "checkListViewChartBinding (function)", - "checkPageComponentViewData (function)", "checkPageDefaultPlacement (function)", "checkPagePrintComposition (function)", "checkPageRegionPlacement (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index c3ff99e88ea..410381de552 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -486,7 +486,6 @@ "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", "checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)", - "checkPageComponentViewData": "src/ui/page.zod.ts#checkPageComponentViewData (function)", "checkPageDefaultPlacement": "src/ui/page.zod.ts#checkPageDefaultPlacement (function)", "checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)", "checkPageRegionPlacement": "src/ui/page.zod.ts#checkPageRegionPlacement (function)", diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts index 0d8ed992fda..f7e108223de 100644 --- a/packages/spec/src/conversions/refused-shapes.test.ts +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -360,8 +360,9 @@ describe('#23317 — a stored page whose component binds the retired API source const todo = todos[0]!; expect(todo.reason).toContain(`The \`${type}\` component \`block\` of page \`partner_page\`, at pages[0].regions[0].components[0], binds the retired view-level API data source.`); expect(todo.reason).toMatch(/No mechanical move: the successor is a page component's `dataSource` binding/); - expect(todo.reason).toMatch(/Left as stored, the parse refuses the page: it still loads and is served, registered under `\[metadata_spec_invalid\]`/); - expect(todo.reason).toMatch(/its next save through the Studio, REST or MCP is refused with 422/); + expect(todo.reason).toMatch(/Left as stored, the page still loads and is served as stored, with no read diagnostic for the binding/); + expect(todo.reason).toMatch(/its next save through the Studio, REST or MCP is refused with 422 by the component-props gate/); + expect(todo.reason).not.toContain('metadata_spec_invalid'); expect(todo.reason).toMatch(PRESCRIPTION); }); @@ -375,12 +376,6 @@ describe('#23317 — a stored page whose component binds the retired API source 'pages[0].regions[0].components[0].properties.items[0].children[0].properties.data', 'pages[0].slots.details.properties.data', ]); - // [#23357] …and the page parse refuses exactly those two positions. - const verdict = PageSchema.safeParse(row); - expect(verdict.success ? [] : verdict.error.issues.map((i) => i.path.join('.'))).toEqual([ - 'regions.0.components.0.properties.items.0.children.0.properties.data.provider', - 'slots.details.properties.data.provider', - ]); }); it('never prints a credential: the URL and the method are shown, every other request key is withheld', () => { @@ -421,26 +416,24 @@ describe('#23317 — a stored page whose component binds the retired API source } }); - // [#23357] Until the page parse judged this binding the fate read "the page - // still loads and saves", and this pin asserted `PageSchema` ACCEPTED the - // row. Since `checkPageComponentViewData` the page parse refuses each listed - // component at the listed position with the same prescription, which is - // what the save door's 422 and the read / boot `_diagnostics` resolve. - it('the fate it states holds: the page parse refuses each listed component at the listed position, and so does its props row', () => { + // The fate it states, from this package's side: the page parse ACCEPTS the + // stored row — so the boot and read paths, which resolve that parse, raise no + // diagnostic for it — and each listed component's own props row refuses the + // binding at `data.provider` with the prescription, which is the verdict the + // component-props gate raises at the CLI doors and the runtime save door + // (that door's 422 is pinned in `@objectstack/metadata-protocol`'s + // `protocol.runtime-authoring-gate.test.ts`). + it('the fate it states holds: the page parse accepts the row, and each listed component\'s props row refuses it with the prescription', () => { const row = pageRow(VIEW_DATA_COMPONENTS.map((type, i) => component(type, apiData(), `b${i}`))); const { todos } = storedTodos('page', row); - const verdict = PageSchema.safeParse(row); - expect(verdict.success).toBe(false); - const refused = verdict.success ? [] : verdict.error.issues; - // The parse locates the issue at `…properties.data.provider`; the listing - // names the `data` prop it would rewrite, in the conversion walker's spelling. - const listedAt = (path: readonly PropertyKey[]) => - `pages[0]${path.slice(0, -1).map((k) => (typeof k === 'number' ? `[${k}]` : `.${String(k)}`)).join('')}`; - expect(refused.map((i) => listedAt(i.path))).toEqual(todos.map((t) => t.path)); expect(todos).toHaveLength(VIEW_DATA_COMPONENTS.length); - for (const issue of refused) expect(issue.message).toMatch(PRESCRIPTION); + const parsed = PageSchema.safeParse(row); + expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); for (const type of VIEW_DATA_COMPONENTS) { - expect(ComponentPropsMap[type].safeParse({ objectName: 'crm_case', data: apiData() }).success, type).toBe(false); + const verdict = ComponentPropsMap[type].safeParse({ objectName: 'crm_case', data: apiData() }); + expect(verdict.success, type).toBe(false); + const atProvider = verdict.success ? [] : verdict.error.issues.filter((i) => i.path.join('.') === 'data.provider'); + expect(atProvider.map((i) => i.message), type).toEqual([expect.stringMatching(PRESCRIPTION)]); } }); diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index 74ac33bc530..3ece73f2a8a 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -35,9 +35,8 @@ * `controlled_by_parent` refinement's * ({@link controlledByParentMasterRefusal}), `ViewDataSchema`'s parse — for * a page component, through the component's own props row, which the - * component-props gate parses and whose verdict on this one retirement the - * page parse shares (`checkPageComponentViewData`, #23357) — and the - * value-slot judge + * component-props gate parses at the CLI doors and at the runtime save + * door — and the value-slot judge * `AutomationEngine.registerFlow` and `objectstack validate` run * ({@link flowNodeValueTemplateRefusals}). A site is listed here exactly * when that door refuses it. @@ -215,10 +214,11 @@ interface SafeParseable { * list of types to keep in step: a row whose `data` is a bare array refuses * the binding for its shape, never with this prescription. * - * Since #23357 the page parse refuses this one binding too - * (`checkPageComponentViewData`, `ui/page.zod.ts`), on the same four types and - * with the same prescription, so a listed site is refused at the save door as - * well; `refused-shapes.test.ts` pins the two judges to one set of types. + * That gate runs at the CLI doors (`os validate`, `os build`, `os lint`) and at + * the runtime save door (the Studio, REST and MCP write path), so a listed site + * is refused at both. The page parse does not judge it: the boot and read + * paths parse the page and run no authoring rule, so a stored page carrying + * the binding loads and is served with no diagnostic for it. */ function componentApiBindingRefusal(type: unknown, data: unknown): string | undefined { const prescription = apiBindingRefusal(data); @@ -244,10 +244,10 @@ const VIEW_API_BINDING_FATE = const COMPONENT_API_BINDING_FATE = 'No mechanical move: the successor is a page component\'s `dataSource` binding, which takes a same-origin ' + '`url` and no other request key, so the request cannot be carried over as written; and deleting `data` ' - + 'would make the component query its object instead and change what it shows. Left as stored, the parse ' - + 'refuses the page: it still loads and is served, registered under `[metadata_spec_invalid]` with the ' - + 'refusal in the `_diagnostics` of every read, and its next save through the Studio, REST or MCP is refused ' - + 'with 422, as `os validate`, `os build` and `os lint` refuse the binding in source. Decide what the component ' + + 'would make the component query its object instead and change what it shows. Left as stored, the page ' + + 'still loads and is served as stored, with no read diagnostic for the binding, and the console no longer ' + + 'reads the retired arm, so it sends no request; its next save through the Studio, REST or MCP is refused with 422 by the component-props ' + + 'gate, as `os validate`, `os build` and `os lint` refuse the binding in source. Decide what the component ' + 'binds now, and which same-origin endpoint, if any, replaces the request.'; const viewDataApiProvider: RefusedStoredShape = { diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts index 14625979538..b2fe7ba7a83 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-declarative-update-rules.ts @@ -23,8 +23,10 @@ export const entry: SemanticMigration = { + 'is dropped before the write. `ActionSchema` refused each of these shapes on an action; the rows accepted ' + 'them. The rows now apply the same judge, read from one place, with the action\'s own texts (the executor ' + 'spelled as the row spells it), and take `ActionSchema`\'s own `patch` record by reference. A page\'s ' - + '`properties` is not parsed on the metadata save or load path; `os validate`\'s props gate parses it ' - + 'against these same rows and reports each refusal at the row\'s own key. Measured producers: none in this ' + + '`properties` is not parsed by the page schema; the component-props gate parses it against these same rows, ' + + 'at `os validate`, `os build`, `os lint` and the metadata save door, and reports each refusal at the row\'s ' + + 'own key; the load path runs no such gate, so a stored page still loads and its next save meets the ' + + 'component-props gate at the metadata save door. Measured producers: none in this ' + 'repository\'s examples, CLI corpus or tests outside these rows\' own pins; in objectui, at its pin and ' + 'main, one renderer-test control mounts an `action:button` with `patch` and no `operation` to show nothing ' + 'is written, and no spec parse judges it.', diff --git a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts index 8c65cd22ac8..261d8b4473f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.view-data-api-provider-retired.ts @@ -37,9 +37,9 @@ export const entry: SemanticMigration = { + 'move either: deleting its `data` would make it query its object instead.', acceptanceCriteria: "No view in `stack.views[]`, in any object's `listViews` / `formViews` map or in a stored " + "`sys_metadata` view row carries `data.provider: 'api'`, and no page component's `data` prop does; the " - + 'parse refuses one in a view and in a page component, at every door that parses a page (the metadata ' - + 'save door\'s 422 included), and the component-props gate of `os validate`, `os build` and `os lint` ' - + 'refuses one in a page component too, with the prescription. ' + + 'view parse refuses one in a view, and the component-props gate refuses one in a page component, each with ' + + 'the prescription, at `os validate`, `os build` and `os lint` and at the metadata save door (its 422, ' + + 'nothing stored). ' + "Every endpoint such a view read is either a page component's API binding on a same-origin path " + 'with no credential key, or dropped on purpose; and every converted view binds its object or static ' + 'rows and loads. `os migrate meta --stored` lists each stored view, and each page component of a stored ' diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index f59ed682708..e0b3f1a2d6d 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -63,7 +63,6 @@ import { checkPageRequiresKind, checkPagePrintComposition, checkPageSlotPair, - checkPageComponentViewData, checkPageDefaultPlacement, checkPageRegionPlacement, } from './page.zod'; @@ -301,53 +300,6 @@ const pageSlotPairFixtures: Fixture[] = [ { label: 'a slot map with neither', value: { ...SLOT_PAGE, slots: { discussion: [] } }, refusesAt: [] }, ]; -// [#23357] A component's `data` prop naming the retired view-level `api` -// provider is refused at the component's `properties.data.provider`, at any -// depth under `regions` and `slots`, on the four types whose `data` is a view -// data source. No fixture here carries `print`, `source`, `requires` or a -// `details` + `tabs` pair, so the four siblings stay silent and each row -// exercises THIS check alone. The provider is spelled through a constant: the -// spec's tree-scoped absence pin reads authored literals, and these bodies are -// refusals, not authorings. -const RETIRED_PROVIDER = 'api' as const; -const RETIRED_BINDING = { provider: RETIRED_PROVIDER, read: { url: 'https://partner.example.com/cases' } } as const; -const viewDataBlock = (type: string, data: unknown) => ({ id: 'cases', type, properties: { objectName: 'crm_case', data } }); -const pageComponentViewDataFixtures: Fixture[] = [ - { - label: 'an `object-grid` in a region binding the retired provider', - value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('object-grid', RETIRED_BINDING)] }] }, - refusesAt: ['regions.0.components.0.properties.data.provider'], - }, - { - label: 'an `object-tree` nested in a `page:tabs` item binding it', - value: { - ...PAGE_BASE, - regions: [{ name: 'main', components: [{ type: 'page:tabs', properties: { items: [{ label: 'Cases', children: [viewDataBlock('object-tree', RETIRED_BINDING)] }] } }] }], - }, - refusesAt: ['regions.0.components.0.properties.items.0.children.0.properties.data.provider'], - }, - { - label: 'an `object-map` in a slot binding it', - value: { ...SLOT_PAGE, slots: { details: viewDataBlock('object-map', RETIRED_BINDING) } }, - refusesAt: ['slots.details.properties.data.provider'], - }, - { - label: 'an `object-gantt` binding its object', - value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('object-gantt', { provider: 'object', object: 'crm_case' })] }] }, - refusesAt: [], - }, - { - label: 'an `object-kanban` (a row-array `data`, its own contract) carrying the retired provider', - value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('object-kanban', RETIRED_BINDING)] }] }, - refusesAt: [], - }, - { - label: 'a type with no props row carrying the retired provider', - value: { ...PAGE_BASE, regions: [{ name: 'main', components: [viewDataBlock('custom:partner-widget', RETIRED_BINDING)] }] }, - refusesAt: [], - }, -]; - // [#23610] `isDefault: true` off a record page bound to an object, and a // region the page's template does not place. No fixture here carries `print`, // `source`, `requires`, a `details` + `tabs` pair or a component `data`, so the @@ -596,7 +548,6 @@ const MIRRORED: MirroredSchema[] = [ { name: 'checkPageRequiresKind', check: checkPageRequiresKind, fixtures: pageRequiresFixtures }, { name: 'checkPagePrintComposition', check: checkPagePrintComposition, fixtures: pagePrintFixtures }, { name: 'checkPageSlotPair', check: checkPageSlotPair, fixtures: pageSlotPairFixtures }, - { name: 'checkPageComponentViewData', check: checkPageComponentViewData, fixtures: pageComponentViewDataFixtures }, { name: 'checkPageDefaultPlacement', check: checkPageDefaultPlacement, fixtures: pageDefaultPlacementFixtures }, { name: 'checkPageRegionPlacement', check: checkPageRegionPlacement, fixtures: pageRegionPlacementFixtures }, ], @@ -753,13 +704,19 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); - it('page.zod.ts declares all seven exports and attaches each to PageSchema', () => { + it('page.zod.ts declares all six exports and attaches each to PageSchema', () => { const src = read('page.zod.ts'); - for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair', 'checkPageComponentViewData', 'checkPageDefaultPlacement', 'checkPageRegionPlacement']) { + for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair', 'checkPageDefaultPlacement', 'checkPageRegionPlacement']) { expect(src).toContain(`export function ${name}(`); expect(declarations(src, name)).toBe(1); expect(attachments(src, name)).toBe(1); } + // `checkPageComponentViewData` was deleted once `validateComponentProps` + // judged a page component's `properties` at the save door too: one + // operation, one implementation, the props rule's. A re-attachment would be + // a second judge of the same bag. + expect(declarations(src, 'checkPageComponentViewData')).toBe(0); + expect(attachments(src, 'checkPageComponentViewData')).toBe(0); }); it('dashboard.zod.ts declares the export and attaches it to GlobalFilterSchema', () => { @@ -790,7 +747,6 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi ['checkPageRequiresKind', checkPageRequiresKind], ['checkPagePrintComposition', checkPagePrintComposition], ['checkPageSlotPair', checkPageSlotPair], - ['checkPageComponentViewData', checkPageComponentViewData], ['checkPageDefaultPlacement', checkPageDefaultPlacement], ['checkPageRegionPlacement', checkPageRegionPlacement], ['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue], @@ -808,4 +764,8 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi it('no longer exports `checkListViewPageMount` — retired with the mount it policed', () => { expect('checkListViewPageMount' in (ui as Record)).toBe(false); }); + + it('no longer exports `checkPageComponentViewData` — the props rule judges the component bag', () => { + expect('checkPageComponentViewData' in (ui as Record)).toBe(false); + }); }); diff --git a/packages/spec/src/ui/page-component-view-data.test.ts b/packages/spec/src/ui/page-component-view-data.test.ts deleted file mode 100644 index 52738d89343..00000000000 --- a/packages/spec/src/ui/page-component-view-data.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, expect, it } from 'vitest'; -import type { z } from 'zod'; - -import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; -import { ObjectStackSchema } from '../stack.zod'; -import { ComponentPropsMap } from './component.zod'; -import { PageSchema, definePage } from './page.zod'; -import { ViewDataSchema } from './view.zod'; - -// ─── #23357: the page parse refuses a component's retired `api` data binding ── -// -// The view-level `api` data source is retired (`view.zod.ts`, ADR-0049): a -// view's own `data` naming it is refused by `ViewDataSchema`'s parse. A page -// component whose `data` prop is that same schema (`object-grid`, `object-map`, -// `object-gantt`, `object-tree`) took the same arm, but the page parse leaves -// `properties` an open record and the component-props gate that judges the bag -// runs at the CLI doors only. So a page saved through the Studio, REST or MCP -// kept the binding. `checkPageComponentViewData` is the narrow judge triage -// ruled: this one retirement, at every door that parses a page. -// -// Assertion set (the `view-data-api-provider-retirement.test.ts` precedent): a -// schema refusal raises a `ZodError` whose issues carry `code` and `path` but no -// ADR-0112 `status`, so these pins assert the refusal, the issue `code`, the -// `path` naming WHICH component refused, and the prescription text — the -// retirement's own, byte for byte (the 422 envelope at the save door is pinned -// in `@objectstack/metadata-protocol`'s `protocol.save-union-issues.test.ts`). -// -// The provider is spelled through a constant: the spec's tree-scoped absence pin -// reads authored literals, and these bodies are refusals, not authorings. - -const RETIRED_PROVIDER = 'api' as const; -const RETIRED_BINDING = { - provider: RETIRED_PROVIDER, - read: { url: 'https://partner.example.com/cases', method: 'GET', headers: { Authorization: 'Bearer s3cr3t' } }, -} as const; - -/** The four types whose `data` is a view data source, as the D3 entry names them. */ -const VIEW_DATA_COMPONENTS = ['object-grid', 'object-map', 'object-gantt', 'object-tree'] as const; - -/** The retirement's own prescription — read off `ViewDataSchema`, never retyped. */ -const PRESCRIPTION = (() => { - const verdict = ViewDataSchema.safeParse(RETIRED_BINDING); - if (verdict.success) throw new Error('ViewDataSchema accepts the retired provider — the retirement is gone'); - return verdict.error.issues.find((issue) => issue.path.join('.') === 'provider')!.message; -})(); - -const block = (type: string, data: unknown, id = 'cases') => ({ id, type, properties: { objectName: 'crm_case', data } }); -const pageOf = (components: unknown[], extra: Record = {}) => ({ - name: 'partner_page', - label: 'Partner', - type: 'app', - regions: [{ name: 'main', components }], - ...extra, -}); - -function refusalsOf(result: z.ZodSafeParseResult) { - return result.success ? [] : result.error.issues.map((issue) => ({ code: issue.code, path: issue.path.join('.'), message: issue.message })); -} - -describe('PageSchema — a component `data` prop naming the retired `api` provider is refused', () => { - it('the prescription read off ViewDataSchema is the retirement\'s (anti-vacuity)', () => { - expect(PRESCRIPTION).toMatch(/^`view\.data\.provider: 'api'` was removed in @objectstack\/spec 18 \(ADR-0049/); - }); - - it.each(VIEW_DATA_COMPONENTS)('%s in a region: one `custom` issue at its `properties.data.provider`, carrying the prescription', (type) => { - expect(refusalsOf(PageSchema.safeParse(pageOf([block(type, RETIRED_BINDING)])))).toEqual([ - { code: 'custom', path: 'regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, - ]); - }); - - it('the bare `{ provider }` is refused the same way — the value, not the request block, is what left', () => { - expect(refusalsOf(PageSchema.safeParse(pageOf([block('object-grid', { provider: RETIRED_PROVIDER })])))).toEqual([ - { code: 'custom', path: 'regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, - ]); - }); - - it('reaches a component nested in another component, at any depth, and a component in a slot', () => { - const tabs = { - id: 'tabs', - type: 'page:tabs', - properties: { items: [{ label: 'Cases', children: [block('object-grid', RETIRED_BINDING, 'in_tab')] }] }, - }; - const flex = { id: 'row', type: 'flex', properties: { children: [{ id: 'card', type: 'page:card', properties: { children: [block('object-tree', RETIRED_BINDING, 'deep')] } }] } }; - const page = pageOf([tabs, flex], { kind: 'slotted', type: 'record', object: 'crm_case', slots: { details: block('object-map', RETIRED_BINDING, 'slotted'), header: [block('object-gantt', RETIRED_BINDING, 'head')] } }); - expect(refusalsOf(PageSchema.safeParse(page)).map((r) => [r.code, r.path])).toEqual([ - ['custom', 'regions.0.components.0.properties.items.0.children.0.properties.data.provider'], - ['custom', 'regions.0.components.1.properties.children.0.properties.children.0.properties.data.provider'], - // The parse emits the slot map in the schema's own key order, `header` first. - ['custom', 'slots.header.0.properties.data.provider'], - ['custom', 'slots.details.properties.data.provider'], - ]); - }); - - it('every door that parses a page refuses it: `definePage`, the `page` metadata-type schema, and the stack', () => { - const page = pageOf([block('object-grid', RETIRED_BINDING)]); - expect(() => definePage(page as Parameters[0])).toThrow(/`view\.data\.provider: 'api'` was removed/); - // The schema the metadata save door and the read / boot diagnostics resolve. - const door = getMetadataTypeSchema('page')!; - expect(refusalsOf(door.safeParse(page))).toEqual([ - { code: 'custom', path: 'regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, - ]); - expect(refusalsOf(ObjectStackSchema.safeParse({ pages: [page] }))).toEqual([ - { code: 'custom', path: 'pages.0.regions.0.components.0.properties.data.provider', message: PRESCRIPTION }, - ]); - }); - - it('a self-referencing component (a hand-built stack object) is judged once and the walk ends', () => { - const loop: { id: string; type: string; properties: { children: unknown[] } } = { id: 'loop', type: 'flex', properties: { children: [] } }; - loop.properties.children.push(loop, block('object-grid', RETIRED_BINDING)); - expect(refusalsOf(PageSchema.safeParse(pageOf([loop]))).map((r) => r.path)).toEqual([ - 'regions.0.components.0.properties.children.1.properties.data.provider', - ]); - }); -}); - -describe('PageSchema — what the check leaves alone (controls)', () => { - it.each([ - ['the `object` provider', block('object-grid', { provider: 'object', object: 'crm_case' })], - ['the `value` provider', block('object-map', { provider: 'value', items: [{ name: 'a' }] })], - ['the `schema` provider', block('object-tree', { provider: 'schema', schemaId: 'report' })], - ['no `data` at all', { id: 'cases', type: 'object-gantt', properties: { objectName: 'crm_case' } }], - ['another unknown provider — the props gate\'s to judge, not this retirement\'s', block('object-grid', { provider: 'rest' })], - ['a row-array `data` type carrying the retired provider — its own contract', block('object-kanban', RETIRED_BINDING)], - ['a type with no props row carrying the retired provider', block('custom:partner-widget', RETIRED_BINDING)], - ['the governed element binding on the component itself', { - id: 'cases', type: 'object-grid', properties: { objectName: 'crm_case' }, dataSource: { provider: RETIRED_PROVIDER, read: { url: '/api/v1/cases' } }, - }], - ])('accepts: %s', (_label, node) => { - const result = PageSchema.safeParse(pageOf([node])); - expect(result.success, JSON.stringify(refusalsOf(result))).toBe(true); - }); -}); - -describe('the check covers exactly the props rows whose `data` is ViewDataSchema — derived, both directions', () => { - const defOf = (schema: unknown): unknown => (schema as { _zod?: { def?: unknown } } | undefined)?._zod?.def; - /** A row member through its `optional` / `default` / `nullable` wrappers. */ - const unwrapped = (schema: unknown): unknown => { - let current = schema; - for (;;) { - const def = defOf(current) as { type?: string; innerType?: unknown } | undefined; - if (!def || !['optional', 'default', 'nullable'].includes(def.type ?? '')) return current; - current = def.innerType; - } - }; - const rowTakesViewData = (type: string): boolean => { - const row = (ComponentPropsMap as Record }>)[type]!; - const member = row.shape?.data; - return member !== undefined && defOf(unwrapped(member)) === defOf(ViewDataSchema); - }; - - it('the derivation from ComponentPropsMap is the four types (anti-vacuity: it is not empty, and the map is populated)', () => { - const types = Object.keys(ComponentPropsMap); - expect(types.length).toBeGreaterThan(40); - expect(types.filter(rowTakesViewData)).toEqual([...VIEW_DATA_COMPONENTS]); - }); - - it('for every props row, the page parse refuses the retired binding exactly when the row\'s `data` is ViewDataSchema', () => { - // Read at the binding's own position: a type the vocabulary retired by name - // (`user:profile`, …) is refused at `type` for that, which is not this check. - const AT = 'regions.0.components.0.properties.data.provider'; - for (const type of Object.keys(ComponentPropsMap)) { - const issues = refusalsOf(PageSchema.safeParse(pageOf([block(type, RETIRED_BINDING)]))); - expect(issues.some((issue) => issue.path === AT), type).toBe(rowTakesViewData(type)); - } - }); -}); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index e8a47b21d00..9acd8ec26dd 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -17,7 +17,6 @@ import { AddRecordConfigSchema, ListColumnSchema, EmptyStateSchema, - ViewDataSchema, } from './view.zod'; import { lazySchema } from '../shared/lazy-schema'; @@ -1115,113 +1114,6 @@ export function checkPageSlotPair( }); } -/** - * The page-component types whose `data` prop is a view data source — the - * `object-*` blocks whose `ComponentPropsMap` row (`component.zod.ts`) - * declares `data` as {@link ViewDataSchema}. Read by - * {@link checkPageComponentViewData} and by nothing else. - * - * Declared here, not derived, because this module cannot read those rows: - * `component.zod.ts` imports this module (`ElementDataSourceSchema`, - * `RETIRED_PAGE_COMPONENT_TYPES`), and this package has no import cycle. The - * set is held equal to the derivation — every row whose `data` member is - * `ViewDataSchema` by identity, and no other — by - * `page-component-view-data.test.ts`, so a row that gains or loses the view - * data source fails that pin the day it lands (the - * {@link PRINT_REFUSED_PAGE_COMPONENT_TYPES} discipline). A type whose row - * takes a row array as `data` (`object-kanban`, `object-calendar`, - * `object-timeline`), and a type with no row (a custom or registered SDUI - * block), is not in it: there `data` is that component's own contract. - */ -const VIEW_DATA_SOURCE_COMPONENT_TYPES: ReadonlySet = new Set([ - 'object-grid', - 'object-map', - 'object-gantt', - 'object-tree', -]); - -/** - * The page parse refuses a component's `data` prop that names the retired - * view-level `api` provider, as a view's own `data` is refused (#23357; the - * retirement is `view.zod.ts`'s, ADR-0049 enforce-or-remove). - * - * Why the page parse: it leaves a component's `properties` an open record, - * and the door that judges the bag — the component-props gate, against the - * type's `ComponentPropsMap` row — runs at the CLI doors only, a rollout - * decision this check does not reopen. So a page saved through the Studio, - * REST or MCP kept a component's retired binding, and the console kept - * sending its request with the stored headers. This check is the narrow - * judge: one retirement, at every door that parses a page (`definePage`, - * `defineStack`, the metadata save door's `422 INVALID_METADATA`, and the - * read and boot diagnostics), and nothing else about the bag. - * - * The judge is the retirement's own. `ViewDataSchema` parses the component's - * `data`, and the prescription its issue at `provider` carries is raised - * here, located at the component's `properties.data.provider`. Nothing is - * copied, so a page and a view refuse with one text. The code is `custom`, - * the code of every object-level check on this schema (the view's own - * `invalid_union` is the discriminated union's). - * - * Reach: every component node under `regions` and `slots`, at any depth — a - * node nested in another component's `properties` (a `page:tabs` item's - * `children`, a container's `children`) included. The canonical slot list - * (`pageComponentSlotPositions()`) lives in `component.zod.ts`, out of this - * module's reach for the cycle reason above, so the walk descends every - * object and array under `regions` and `slots` instead. That is a superset of - * every slot position, so it reaches no less than `os migrate meta --stored` - * lists or the props gate judges, and what it judges at a node stays narrow: - * a type in {@link VIEW_DATA_SOURCE_COMPONENT_TYPES} whose `properties.data` - * names the retired provider. A binding found anywhere else is still a - * request, and its headers, stored in metadata and served to every reader. - * - * Exported for the reason {@link checkPageSourceCompleteness} is: a - * downstream mirror that derives its schema from `PageSchema.shape` drops - * every object-level check, and re-attaches this one with - * `.superRefine(checkPageComponentViewData)`. `PageSchema` attaches this same - * binding — pinned in `object-refinement-check-exports.test.ts`. - */ -export function checkPageComponentViewData( - page: { regions?: unknown; slots?: unknown }, - ctx: z.RefinementCtx, -): void { - // A hand-built `defineStack` object can share a node or refer back to one; - // each object is visited once. - const seen = new Set(); - const visit = (value: unknown, path: PropertyKey[]): void => { - if (value === null || typeof value !== 'object' || seen.has(value)) return; - seen.add(value); - if (Array.isArray(value)) { - value.forEach((entry, i) => visit(entry, [...path, i])); - return; - } - const node = value as Record; - const properties = node.properties; - if ( - typeof node.type === 'string' && VIEW_DATA_SOURCE_COMPONENT_TYPES.has(node.type) - && properties !== null && typeof properties === 'object' && !Array.isArray(properties) - ) { - const bound = (properties as Record).data; - // The retired spelling only: any other `data` is the props gate's to judge. - if (bound !== null && typeof bound === 'object' && (bound as { provider?: unknown }).provider === 'api') { - const verdict = ViewDataSchema.safeParse(bound); - const retirement = verdict.success - ? undefined - : verdict.error.issues.find((issue) => issue.path.length === 1 && issue.path[0] === 'provider'); - if (retirement !== undefined) { - ctx.addIssue({ - code: 'custom', - message: retirement.message, - path: [...path, 'properties', 'data', ...retirement.path], - }); - } - } - } - for (const [key, child] of Object.entries(node)) visit(child, [...path, key]); - }; - visit(page.regions, ['regions']); - visit(page.slots, ['slots']); -} - /** * Page Schema * Defines a composition of components for a specific context. @@ -2042,21 +1934,19 @@ export const PageSchema = lazySchema(() => strictObject({ // slot replaces the strip the Details tab lives in. Attached by identifier // for the same reason as the checks above. .superRefine(checkPageSlotPair) - // #23357: a component's `data` prop naming the retired view-level `api` - // provider is refused by the page parse, as a view's own `data` is. - // Attached by identifier for the same reason as the checks above. - .superRefine(checkPageComponentViewData) // #23610 (ruling A, record 6190536588): `isDefault: true` on a page that is // not a record page bound to an object, and a region the page's template // does not place, are refused. Attached by identifier for the same reason // as the checks above. .superRefine(checkPageDefaultPlacement) .superRefine(checkPageRegionPlacement)); -// PageSchema's cross-field rules are the seven checks above: the +// PageSchema's cross-field rules are the six checks above: the // ADR-0080/0081 source completeness check, the compiled-kind `requires` // check, the print-page composition check, the `details` + `tabs` slot -// pair check, the component view-data check, the `isDefault` placement -// check and the region placement check. It once also required +// pair check, the `isDefault` placement check and the region placement +// check. A component's `properties` bag is judged by none of them: it is +// `@objectstack/lint`'s `validateComponentProps`, at the CLI doors and the +// runtime save door alike. It once also required // `recordReview`/`blankLayout` and `slots` (all removed — unrendered roadmap / // "required-but-unauthorable" Studio traps). diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 6247a6df825..b92b80afe31 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -190,9 +190,11 @@ export type { HttpMethodSubset } from '../shared/http.zod'; * `view-data-api-provider-retired` carries the judgement an upgrader owes. * * A page component whose `data` prop is this schema (`object-grid`, - * `object-map`, `object-gantt`, `object-tree`) is refused by the page parse - * with this prescription, read off this union's own issue rather than copied - * (`checkPageComponentViewData`, `ui/page.zod.ts`, #23357). + * `object-map`, `object-gantt`, `object-tree`) meets this prescription through + * its `ComponentPropsMap` row: `@objectstack/lint`'s `validateComponentProps` + * parses the component's `properties` against that row at the CLI doors and at + * the runtime save door, and raises this union's own issue. The page parse + * itself leaves `properties` an open record. */ const VIEW_DATA_API_PROVIDER_RETIRED = "`view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — "