Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/10872-element-number-arm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/types': minor
---

`safeValidateSchema` — and so `objectui validate` — accepts `element:number`, the ADR-0080 public block held back from batch 1, in both of the binding forms `@objectstack/spec` accepts (objectui#10872, batch 2).

**Clause-②: yes** — the accept set of `AnyComponentSchema` widens by one `type` literal, and `@object-ui/types/zod` exports one new schema, `ElementNumberBlockSchema`. Nothing that parsed before is refused now.

**What it was.** `element:number` is registered, curated as platform contract by ADR-0080 and declared by the spec (`ComponentPropsMap['element:number']`), while `AnyComponentSchema` carried no arm for it — so every `element:number` document was refused with `invalid_union` at `type`, and `objectui validate` reported "Schema validation failed" for any page that placed one.

**What changed, in observable terms.**

- `@object-ui/types/zod` exports `ElementNumberBlockSchema`, a member of `PublicBlockComponentSchema`; the strict authoring face (`StrictAnyComponentSchema`) accepts the same documents, closed to undeclared keys like every other arm.
- The props live where the spec puts them: `properties`, read by reference from the block's `ComponentPropsMap` row — its members, value types, retired-key refusals and strictness. `{ "type": "element:number", "properties": { "object": "order", "aggregate": "count" } }` is accepted; a `format` outside `number` / `currency` / `percent`, or a record-form `filter` (the spec's migration `element-number-filter-rule-array`), is refused at that member, and an undeclared prop is refused by name (`unrecognized_keys` on `properties`).
- The spec's one waiver is mirrored: the row requires `object`, and a bag may omit it when the node's `dataSource.object` is a non-empty object name, as the spec's props gate (`validateComponentProps` in `@objectstack/lint`) waives it. `{ "type": "element:number", "dataSource": { "object": "order" }, "properties": { "aggregate": "sum", "field": "total" } }` is accepted.
- A bag that names its object nowhere is refused at `properties.object`, with a message naming both fixes (set `properties.object`, or bind `dataSource.object`). An empty or non-string `dataSource.object` supplies nothing. The waiver covers an omitted `object` only: a wrong one (`"object": 7`) is refused even beside a binding.
- `dataSource` is declared on the node as the spec's `ElementDataSourceSchema`, by reference — the schema `PageComponentSchema.dataSource` is — so its members and its alias refusals (`objectName` → `object`) are the spec's, and the strict face judges a `dataSource` rather than refusing it as undeclared.
- A node with no `properties` bag is not judged against the row, as for every other public block and as the spec's props gate does.
2 changes: 2 additions & 0 deletions .changeset/10872-public-block-zod-arms.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@
- A prop written directly on the node rather than inside `properties` is not judged against the block's row. A key the shared node base (`BaseSchema`) does not declare, such as `{ "type": "record:details", "columns": "2" }`, passes the tolerant face and is refused by the strict face. A key the base does declare is judged by the base instead. `children` (`page:card`, `page:section`, `page:footer`, `page:sidebar`), `label` and `disabled` (`element:button`) and `visible` (`record:alert`) are judged against the base's own types. `body` (`page:card`, `record:alert`) is refused on both faces by the base's objectui#6771 retirement: write a `record:alert` banner's `body` inside `properties`.
- `page:tabs` also refuses an authored `onTabChange`, by name: it is the callback `@object-ui/app-shell` injects onto the node to write `?tab=` back, a runtime slot a React host fills (objectui#6124), and a JSON value there was never a working handler — delete the key.
- Not armed in this release, and still refused at `type`: `element:number` (its row's required `object` is waived by the spec when the node binds through `dataSource`, which the row alone would refuse), `record:line_items` (the spec has no row for it yet), `element:definition-list` and `element:repeater` (the spec's `element:` vocabulary does not declare them), and `action:button`, `action:group`, `action:menu` and `action:icon` (no spec row yet; one is being measured from the renderers' read points upstream, objectstack-ai/objectstack#20371).

⚠️ **Dated note, 2026-09-28 — `element:number` armed — objectui#10872 batch 2.** Later in this same release `element:number` is armed as well (`ElementNumberBlockSchema`, with the spec's `dataSource` waiver on its required `object`), so the lead sentence's "less `element:number`" and the first item of the "Not armed in this release" bullet no longer describe the release as a whole; the other held blocks are still refused at `type`. The rest of this entry is kept as the reading of this change.
2 changes: 2 additions & 0 deletions .changeset/8317-strip-imported-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,5 @@ any of the 107 component arms.
repository's release train). This is reversible into it — every strip becomes a no-op
the day the spec adopts the same principle, because the boundary is the identity
function on a subtree with nothing to strip.

⚠️ **Dated note, 2026-09-28 — one clean import is rebuilt — objectui#10872 batch 2.** Later in this same release the `element:number` arm imports the spec's `ElementDataSourceSchema` through this boundary. It carries no default but reaches a `z.lazy` (the recursive filter clause), and the walker always rebuilds a `lazy`, so for that one import the boundary returns an equal-answering clone rather than the spec's own object: "the identity function on a subtree with nothing to strip" holds for every other clean import, not that one. Nothing is stripped from it and its accept set does not move. The rest of this entry is kept as the reading of this change.
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,11 @@ const REFUSED_AT_TYPE = 73;
/**
* The head's refused count over the NAMESPACED keys (objectui#10872 batch 1:
* 418 on `main` before it, minus the twenty ADR-0080 public blocks armed from
* their `@objectstack/spec` `ComponentPropsMap` rows). LOWER it when a batch
* arms more keys; never raise it.
* their `@objectstack/spec` `ComponentPropsMap` rows; batch 2: minus
* `element:number`, armed with the spec's `dataSource` waiver). LOWER it when
* a batch arms more keys; never raise it.
*/
const NAMESPACED_REFUSED_AT_TYPE = 398;
const NAMESPACED_REFUSED_AT_TYPE = 397;

/** The bare registry keys — the population the card measured. */
const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':'));
Expand All @@ -96,6 +97,9 @@ const ARMED_PUBLIC_BLOCKS_10872 = [
'element:text', 'element:button', 'element:divider',
] as const;

/** The public block objectui#10872 batch 2 armed. */
const ARMED_PUBLIC_BLOCKS_10872_BATCH_2 = ['element:number'] as const;

/** Is `type` unclaimed by every arm of the validator's root union? */
function refusedAtType(type: string): boolean {
const result = safeValidateSchema({ type });
Expand Down Expand Up @@ -185,6 +189,13 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet
expect(refusedAtType(key), key).toBe(false);
}
});

it('counts the public block objectui#10872 batch 2 armed', () => {
for (const key of ARMED_PUBLIC_BLOCKS_10872_BATCH_2) {
expect(NAMESPACED_KEYS, key).toContain(key);
expect(refusedAtType(key), key).toBe(false);
}
});
});

/* ── End to end: the README document through `objectui validate` ─────────── */
Expand Down Expand Up @@ -293,4 +304,39 @@ describe('objectui validate — a page built from ADR-0080 public blocks (object
expect(text).toContain('inventedProp10872');
expect(exitCodes).toEqual([1]);
});

it('validates `element:number` in both of its binding forms (objectui#10872 batch 2)', async () => {
// The props form, and the `dataSource` form the spec's props gate waives
// `properties.object` for.
const page = {
type: 'page',
children: [
{ type: 'element:number', properties: { object: 'order', aggregate: 'count' } },
{ type: 'element:number', dataSource: { object: 'order' }, properties: { aggregate: 'sum', field: 'total' } },
],
};
const file = join(dir, 'element-number-page.json');
writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8');

await validate(file);

const text = out.join('\n').replace(ANSI, '');
expect(text).not.toContain('Schema validation failed');
expect(text).toContain('Schema is valid');
expect(exitCodes).toEqual([0]);
});

it('refuses an `element:number` that names its object nowhere, at `properties.object`', async () => {
// The control that keeps the row above from passing for the wrong reason.
const page = { type: 'page', children: [{ type: 'element:number', properties: { aggregate: 'count' } }] };
const file = join(dir, 'element-number-page-refused.json');
writeFileSync(file, JSON.stringify(page, null, 2), 'utf-8');

await validate(file);

const text = out.join('\n').replace(ANSI, '');
expect(text).toContain('Schema validation failed');
expect(text).toContain('dataSource.object');
expect(exitCodes).toEqual([1]);
});
});
Loading
Loading