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
35 changes: 35 additions & 0 deletions .changeset/25923-list-view-options-bag-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@objectstack/spec': minor
'@object-ui/types': minor
'@object-ui/plugin-list': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-view': patch
'@object-ui/plugin-map': patch
'@object-ui/console': patch
'@objectstack/rest': patch
'@objectstack/metadata-protocol': patch
'@objectstack/lint': patch
---

feat(spec)!: retire the list view's `options` bag; stored bags fold onto the top-level blocks, and the renderers read the blocks alone (#25923)

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered list-view-options-bag-folded, ui-list-view-options-bag-folded -->

A flattened list view overlay carried every per-kind block twice: as the top-level `kanban`, `calendar`, `gallery`, `timeline`, `gantt`, `map`, `tree` and `chart`, and again under a legacy `options` bag the top-level block won over per key. Each renderer re-implemented that merge. The bag is retired, and each block has one spelling.

- **`@objectstack/spec`**: the list overlay's `options` is a removed-key tombstone. The view write door refuses a body that carries it, by name, with the prescription to move each `options.KIND` block to `KIND`. The ADR-0087 D2 conversion `list-view-options-bag-folded` folds a stored bag onto the top-level blocks before a row is served (the top-level value wins per key; for `chart`, whose block replaced the bag whole, a bag beside a block is dropped), and `os migrate meta --from 17` lists the same edits for a source. A bag it cannot fold (an `options.grid`, a non-object block, a bag on a form view) is left in place with a TODO. The D3 entry `ui-list-view-options-bag-folded` names what the fold cannot decide: the bag was judged with every key optional, so a folded block can still miss a key its block requires. The page `list-view` component row shares the tombstone.
- **`@object-ui/types`**: `ListViewSchema.options` takes the spec's tombstone by reference, so every published door refuses a bag at `options` with the spec's text, and the TypeScript face has no bag. `NamedListView.options` is a `?: never` tombstone that names the top-level blocks. The dataset-chart filter refusal reads the `chart` block alone, and the page node's flat `options` is refused with the bag's own text rather than pointed at `properties.options`.
- **`@object-ui/plugin-list`**: `ListView` reads each kind from its top-level block alone: the render branches, the capability gate, the projection and expand collectors and the fetch effect's dependencies no longer read `options`. The `list-view` registration publishes no `options` input.
- **`@object-ui/app-shell`**: the object page relays each per-kind block at the top level of the list schema, held by value per block so an equal rebuild keeps its identity, and no longer forwards a stored bag. The interface page sends one `map` block (its derivation with the authored keys laid over) and no bag. The Studio view preview and the list-view inspector no longer fold a bag; the server does. `storedLegacyOptions` is removed.
- **`@object-ui/plugin-view`**: the host list schema carries the active view's blocks at the top level instead of under `options`.
- **`@object-ui/plugin-map`**: two development warnings name the view's `map` block instead of `options.map`.
- **`@object-ui/console`, `@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/lint`**: test pins only (the registry parity row, the write door's refusal and served fold, and the lint finding), no runtime change.

**BREAKING** for a list view body, a page `list-view` node or a `ListView` host that writes `options`.

- **Before.** `{ type: 'kanban', columns: ['name'], options: { kanban: { groupByField: 'stage' } } }` was accepted at the view write door and drew a board laned on `stage`, and `ListView` read the bag beside the top-level blocks.
- **Now.** The write door and every `@object-ui/types` door refuse `options` by name, and `ListView` reads no bag. A view already stored with one is served folded onto its top-level blocks and renders from them. On the object page those blocks now pass through the same relay defaults as a block written top-level (the kanban `titleField: 'name'` floor, for one), so a folded row can draw differently from its bag where the bag left such a key unset. No reachable source stores a bag (census: 0 rows), and `ObjectView.storedRowParity-10380` pins the folded rendering.
- **Fix.** Move each block to the top level: `{ type: 'kanban', columns: ['name'], kanban: { groupByField: 'stage', columns: ['name'] } }`, keeping the top-level value where both set a key, and add any key the block requires that the bag never had (a kanban's `columns`, a gantt's `titleField`, a chart's `dataset` and `values`). A grid's settings are top-level keys of the view. Run `os migrate meta --from 17` for a source, with `--write` to apply the edits it can prove.
2 changes: 1 addition & 1 deletion content/docs/ui/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ regenerates from the renderer registry. Regenerate it with
| [`ins`](/docs/ui/components/html#ins) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
| [`label`](/docs/ui/components/html#label) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
| [`li`](/docs/ui/components/html#li) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 42 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 41 | [Plugin Blocks](/docs/ui/components/plugins) |
| [`main`](/docs/ui/components/html#main) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
| [`mark`](/docs/ui/components/html#mark) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
| [`markdown`](/docs/ui/components/plugins#markdown) | `plugin-markdown` | 2 | [Plugin Blocks](/docs/ui/components/plugins) |
Expand Down
1 change: 0 additions & 1 deletion content/docs/ui/components/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ Namespace `plugin-list`.
| `rowActions` | `array` of `string` | | Names of the actions offered on each row's menu. |
| `bulkActions` | `array` of `string` | | Names of the actions offered once rows are selected. Needs a multi-row `selection`. |
| `bulkActionDefs` | `array` of `object` | | Full bulk-action definitions, the spec's `BulkActionDef`: a mass update (`operation: "update"` with a `patch`) or delete, or a named action run once for the whole selection. |
| `options` | `object` | | Per-kind settings, `{ kanban, calendar, gantt, gallery, timeline, map, tree, chart }`, each judged by that kind's own block. The top-level block of the same name is the canonical spelling. |
| `kanban` | `object` | | The board, the spec's kanban block: `{ groupByField, columns, titleField, summarizeField }`. `groupByField` is the lane field and `columns` the fields on each card. |
| `calendar` | `object` | | The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`. |
| `gantt` | `object` | | The gantt chart, `{ startDateField, endDateField, titleField, progressField, dependenciesField }`. Both date fields are needed to draw it. |
Expand Down
12 changes: 6 additions & 6 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 217 | 208 | 4 | 0 | 5 |
| `ui/` | 216 | 207 | 4 | 0 | 5 |

## `ui/` — sites

Expand All @@ -44,25 +44,25 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `report.zod.ts` | 4 |
| `responsive.zod.ts` | 1 |
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 59 |
| `view.zod.ts` | 58 |
| `widget.zod.ts` | 1 |
| **total** | **217** |
| **total** | **216** |

## `ui/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**5 strip of 217**, in 4 file(s).
**5 strip of 216**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 2 | 59 |
| `view.zod.ts` | 2 | 58 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **5** | **217** |
| **total** | **5** | **216** |

| Bucket | Sites |
|---|---|
Expand Down
18 changes: 11 additions & 7 deletions packages/lint/src/validate-component-props-list-view-22444.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
* a bag holding `kanban.groupField` or `options.grid` — refused at every list
* view door — drew no finding at all (the census on the card measured 0). The
* row needs no dispatch change here: the rule reads `ComponentPropsMap` by
* type. The pins are the ruling's list, at this door: `kanban.groupField` and
* `options.grid` flagged naming the key; contract keys only pass; the retired
* `objectName` / `viewType` flagged with the react tier's prescription. The
* type. The pins are the ruling's list, at this door: `kanban.groupField`
* flagged naming the key; an `options` bag flagged as the retired key it is
* since #25923 (the list overlay's tombstone, with its prescription); contract
* keys only pass; the retired `objectName` / `viewType` flagged with the react
* tier's prescription. The
* schema half is pinned in `@objectstack/spec`
* (`ui/component-list-view-row.test.ts`).
*
Expand All @@ -21,7 +23,7 @@ import { REACT_RETIRED_OVERLAY_PROPS } from '@objectstack/spec/ui';
import { describe, expect, it } from 'vitest';

import { runAuthoringRules } from './authoring-rules.js';
import { COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js';
import { COMPONENT_PROPS_INVALID, COMPONENT_PROPS_UNKNOWN_KEY, validateComponentProps } from './validate-component-props.js';

type AnyRec = Record<string, unknown>;

Expand All @@ -45,9 +47,11 @@ describe('a page `list-view` node is judged as a list view (#22444)', () => {
expect(shape(findings)).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, 'error', `${AT}.properties.kanban.groupField`]]);
});

it('`options.grid` is flagged, naming the key', () => {
it('an `options` bag is flagged as the retired key, with the list overlay\'s prescription', () => {
const findings = judge({ ...BOUND, options: { grid: { pageSize: 10 } } });
expect(shape(findings)).toEqual([[COMPONENT_PROPS_UNKNOWN_KEY, 'error', `${AT}.properties.options.grid`]]);
expect(shape(findings)).toEqual([[COMPONENT_PROPS_INVALID, 'error', `${AT}.properties.options`]]);
expect(findings[0]!.message).toContain("A list view's `options` bag was removed in @objectstack/spec 18.3.0");
expect(findings[0]!.message).toContain('a retired key is refused');
});

it.each([
Expand Down Expand Up @@ -75,7 +79,7 @@ describe('a page `list-view` node is judged as a list view (#22444)', () => {
expect(mine.map((f) => [f.rule, f.path]).sort()).toEqual(
[
[COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.objectName`],
[COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.options.grid`],
[COMPONENT_PROPS_INVALID, `${AT}.properties.options`],
].sort(),
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,22 +383,22 @@ describe('graftFoldedFormSections — structural safety, no save involved', () =
});

/**
* [#20051] The view write door judges a flat list overlay's legacy `options`
* bag — driven through the REAL `saveMetaItem`, riding this file's pinned
* engine double rather than minting a second one (the double is the reason
* these cases live here, not the `groups` fold).
* [#25923] The view write door refuses a flat list overlay's `options` bag by
* name — driven through the REAL `saveMetaItem`, riding this file's pinned
* engine double rather than minting a second one. The read half (a STORED bag
* served folded) is pinned in `protocol.stored-conversions.test.ts`.
*
* Measured before the change on `origin/main` @ `8d1f7ab`, through this same
* harness: `options: { timeline: { metaFields: ['region'] } }` on a flat list
* overlay saved `success: true` and the row held the bag byte-for-byte, while
* the direct `timeline.metaFields` was refused. `saveMetaItem` stores the
* request body, not the parse output, so what the door does not judge is what
* reaches storage.
* From #20051 until #25923 the door judged the bag key by key and stored a
* legal one byte-for-byte (`options.map`, the path objectui pinned). The bag
* is retired now: the save is refused with the tombstone's prescription, and
* a row written before is replayed through `list-view-options-bag-folded` on
* every stored-row read.
*
* Refusals assert the envelope (`code` + `status`) and that NO row was written.
*/
describe('[#20051] the save door judges a flat list overlay\'s `options` bag', () => {
const TIMELINE = { startDateField: 'created_at', titleField: 'name', metaFields: ['region'] };
describe('[#25923] the save door refuses a flat list overlay\'s `options` bag', () => {
const TIMELINE = { startDateField: 'created_at', titleField: 'name' };
const MAP = { locationField: 'location', titleField: 'legacy_title' };
const flatList = (extra: Record<string, unknown>) => ({
name: 'crm_lead.timeline',
object: 'crm_lead',
Expand All @@ -418,50 +418,25 @@ describe('[#20051] the save door judges a flat list overlay\'s `options` bag', (
return { err, rowsWritten: Array.from(rows.values()).filter((r) => r.type === 'view').length };
}

it('a direct and an `options`-wrapped out-of-contract key get the same refusal, and nothing is stored', async () => {
const direct = await refusal(flatList({ timeline: TIMELINE }));
const wrapped = await refusal(flatList({ options: { timeline: TIMELINE } }));
for (const { err, rowsWritten } of [direct, wrapped]) {
expect(err?.code).toBe('INVALID_METADATA');
expect(err?.status).toBe(422);
expect(rowsWritten).toBe(0);
}
const hit = (err: any, path: string) =>
(err.issues as Array<{ path: string; code?: string; message: string }>)
.find((i) => i.path === path && i.code === 'unrecognized_keys');
const directHit = hit(direct.err, 'timeline');
const wrappedHit = hit(wrapped.err, 'options.timeline');
expect(directHit, JSON.stringify(direct.err.issues)).toBeDefined();
expect(wrappedHit, JSON.stringify(wrapped.err.issues)).toBeDefined();
// Refused by NAME, on the same surface text as the direct spelling.
expect(wrappedHit!.message).toBe(directHit!.message);
expect(wrappedHit!.message).toContain('`metaFields`');
});

it('an unknown key in the bag itself is refused by name, not dropped', async () => {
const { err, rowsWritten } = await refusal(flatList({ timeline: { startDateField: 'created_at', titleField: 'name' }, options: { foo: 1 } }));
it.each([
['a legal `options.timeline` block', { options: { timeline: TIMELINE } }],
['the legacy `options.map` bag objectui pinned', { type: 'map', options: { map: MAP } }],
['a bag beside the same top-level block', { timeline: TIMELINE, options: { timeline: { titleField: 'subject' } } }],
])('%s: 422 at `options` with the move-to-the-top-level prescription, and nothing is stored', async (_label, extra) => {
const { err, rowsWritten } = await refusal(flatList(extra));
expect(err?.code).toBe('INVALID_METADATA');
expect(err?.status).toBe(422);
expect(rowsWritten).toBe(0);
expect((err.issues as Array<{ path: string; message: string }>).some((i) => i.path === 'options' && i.message.includes('`foo`')))
.toBe(true);
const hit = (err.issues as Array<{ path: string; message: string }>).find((i) => i.path === 'options');
expect(hit, JSON.stringify(err.issues)).toBeDefined();
expect(hit!.message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec 18\.3\.0/);
expect(hit!.message).toContain('Move each `options.KIND` block to `KIND`');
});

it('a legal legacy `options.map` bag (the path objectui pins) saves, and the row carries it unchanged', async () => {
const authored = {
name: 'showcase_task.work_map',
object: 'showcase_task',
viewKind: 'list',
label: 'Work Map',
type: 'map',
columns: ['title', 'location'],
options: { map: { locationField: 'location', titleField: 'legacy_title' } },
};
const body = await storedViewBody('showcase_task.work_map', authored);
expect(body.options).toEqual(authored.options);
// …and the stored row passes the same door again: a GET → PUT of it is
// not refused by the judgement it was saved under.
expect((ViewMetadataSchema as any).safeParse(body).success).toBe(true);
it('CONTROL: the same block written top-level saves, and the row carries it', async () => {
const body = await storedViewBody('crm_lead.timeline', flatList({ timeline: TIMELINE }));
expect(body.timeline).toEqual(TIMELINE);
expect('options' in body).toBe(false);
});
});

Expand Down
35 changes: 35 additions & 0 deletions packages/metadata-protocol/src/protocol.stored-conversions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,41 @@ describe('getMetaItem — single stored read is canonical (#3903)', () => {
});
});

// [#25923] A protocol-18 list view row written while the overlay still took
// the legacy `options` bag (17.5.0 to 18.2.0 judged it per kind and stored it
// as sent). The bag is retired now; `list-view-options-bag-folded` is
// `retiredFromLoadPath`, so only this stored seam replays it.
const legacyBagViewRow = {
type: 'view',
name: 'showcase_task.work_map',
metadata: {
name: 'showcase_task.work_map',
object: 'showcase_task',
viewKind: 'list',
label: 'Work Map',
type: 'map',
columns: ['title', 'location'],
map: { locationField: 'location' },
options: { map: { locationField: 'address', titleField: 'legacy_title' } },
},
};

describe('[#25923] a stored list view\'s `options` bag is served folded onto the top-level block', () => {
it('the single and the list read move `options.KIND` to `KIND`, the top-level value winning per key', async () => {
const { engine, rows } = makeStubEngine([legacyBagViewRow]);
const protocol = new ObjectStackProtocolImplementation(engine);
const single: any = await protocol.getMetaItem({ type: 'view', name: 'showcase_task.work_map' });
const listed = ((await protocol.getMetaItems({ type: 'view' })).items as any[])
.find((i) => i.name === 'showcase_task.work_map');
for (const item of [single.item, listed]) {
expect(item.map).toEqual({ locationField: 'location', titleField: 'legacy_title' });
expect('options' in item).toBe(false);
}
// The row at rest is not rewritten by the reads.
expect(JSON.parse(rows[0]!.metadata).options).toEqual(legacyBagViewRow.metadata.options);
});
});

describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3903)', () => {
it('registers the CONVERTED body and reports invalid: 0 for chain-owned history', async () => {
const { engine, registered } = makeStubEngine([legacyObjectRow, legacyActionRow]);
Expand Down
Loading
Loading