Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/23447-page-component-view-data-judge-deleted.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (already-registered view-data-api-provider-retired) the retirement this check enforced is that step-18 entry; this change deletes the second implementation of its page-component half, and the entry's acceptance criteria now name the component-props gate at the CLI doors and the save door as the one judge -->
14 changes: 7 additions & 7 deletions .changeset/pre/23357-page-component-view-data-api-refused.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
---
'@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: '<object_name>'`, 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.

<!-- adr-0087: not-required (already-registered view-data-api-provider-retired) the step-18 entry already names the data prop of these four page components in its scope; this change makes the page parse refuse what that entry retired, and its acceptance criteria now name the page parse beside the component-props gate -->
<!-- adr-0087: not-required (already-registered view-data-api-provider-retired) the step-18 entry already names the data prop of these four page components in its scope; the component-props gate refuses what that entry retired at the CLI doors and the save door, and its acceptance criteria name that gate -->
13 changes: 8 additions & 5 deletions packages/lint/src/validate-component-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
133 changes: 133 additions & 0 deletions packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => ({
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<string, Row>) =>
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<typeof vi.spyOn>;
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);
});
});
Loading
Loading