Skip to content

Commit 8eeb3b3

Browse files
feat(spec,types,plugin-list,app-shell)!: retire the list view's options bag; stored bags fold onto the top-level blocks, and the renderers read the blocks alone (#27206)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBFijSMgUXzsRVhP3BchJpt1)_ Part of #25923 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: skills/objectstack-ui/references/react-blocks.md regenerated by `pnpm gen:react-blocks`; the `list-view` table drops its `options` prop row. No other `skills/**` file teaches the bag: skills/objectstack-ui/rules/list-views.md and skills/objectui/guides/page-builder.md already write the top-level blocks only. ADR-0087: registered list-view-options-bag-folded, ui-list-view-options-bag-folded ## 维护者速读 - 改了什么:列表视图的旧 `options` 配置包退役。规范的视图写入口按名拒收 `options` 并提示改写为顶层 `kanban` / `calendar` 等配置块;已存储的配置包由 ADR-0087 D2 转换在下发前折叠到顶层配置块;objectui 的类型镜像直接引用规范的墓碑,`ListView`、对象页中继、界面页、Studio 预览和检查器都只读写顶层配置块。 - 为什么改:同一个配置块有两种写法,每个渲染器各自实现一遍合并(#12);普查在所有可达元数据里找到 0 行存储的配置包,席位裁定走 A 方案一个 PR 落地(claim amendment 6460225523)。 - 风险与回滚:直接写 `options` 的 PUT 请求、页面 `list-view` 节点或 `ListView` 宿主会被拒收或不再生效;已存储的行由服务端折叠,渲染不变。回滚即还原本 PR;D2 转换只在读取时折叠,不改写存储数据。 - 先看:`packages/spec/src/ui/view.zod.ts`(墓碑)、`packages/spec/src/conversions/registry.ts`(`list-view-options-bag-folded`)、`ui/packages/app-shell/src/views/objectViewListRenderer.tsx`(顶层中继)、`ui/packages/plugin-list/src/ListView.tsx`。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/types/src/__tests__/list-view-options-bag-6152.test.ts packages/plugin-list/src/__tests__/ListView.readersRetired-6152.test.tsx packages/app-shell/src/views/ObjectView.storedRowParity-10380.test.tsx`,预期全绿:配置包在每个入口被拒收并给出规范原文,源码普查没有 `schema.options` 读取,存储的配置包折叠后两页渲染一致。 - 需要你做的:无,席位落地 ## What changes - `@objectstack/spec`: the flattened list overlay's `options` is a `retiredKey()` tombstone that refuses a bag by name with the move-each-`options.KIND`-to-`KIND` prescription. The D2 conversion `list-view-options-bag-folded` folds a stored or source bag onto the top-level blocks before a row is served (top-level wins per key; for `chart`, whose block replaced the bag whole, the top-level block wins whole). It leaves `options.grid`, a non-object block and a bag on a form view in place with a TODO. The D3 entry `ui-list-view-options-bag-folded` names what the fold cannot decide (a folded block can still miss a required key). The page `list-view` component row shares the tombstone, and `react-blocks.ts` drops the `options` prop row. - `@object-ui/types`: `ListViewSchema.options` is the spec's tombstone by reference, so the mirror, the tolerant and strict faces and `safeValidateSchema` refuse a bag at `options` with the spec's text, unreworded. `NamedListView.options` is a `?: never` tombstone naming the top-level blocks, moved from held to retired in the `object-view-unmirrored-keys-7779` census (22 tombstones, was 21). The dataset-chart filter refusal reads the `chart` block alone. A page node's flat `options` is refused with the bag text rather than pointed at `properties.options`. - `@object-ui/plugin-list`: `ListView` reads every kind from its top-level block alone (render branches, capability gate, projection and expand collectors, fetch effect dependencies). 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, each held by value through `isSameOptionsValue` so an equal rebuild keeps its identity, and forwards no stored bag (`storedLegacyOptions` removed). The interface page sends one `map` block (its derivation with the authored keys laid over) and no bag. `ViewPreview` and the list-view inspector fold nothing; the server does. - `@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 `map` block. - `@object-ui/console`, `@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/lint`: test pins only. ## Tests - Refusal on the authoring face: `list-view-options-bag-6152` pins the mirror IS the spec tombstone, and every door refuses five bag shapes at `options` with the spec's text, beside a CONTROL that the same blocks written top-level parse. The named alias refusals the bag carried are pinned at the top-level block. - No read left: `ListView.readersRetired-6152` gains a SOURCE census (TypeScript AST over every non-test plugin-list file) that finds no `schema.options` read, with a firing control on `schema.kanban` and a probe of each retired shape. The same scanner finds 39 reads in `ListView.tsx` on `origin/main`. The object page relay census (`ObjectView.relayRungCensus-7559`) declares `options` unread and every block relayed; the interface page census (`InterfaceListPage.relayCensus-11572`) writes `map` and not `options`. - Stored rows convert: the spec and metadata-protocol pins replay `list-view-options-bag-folded` on stored rows; in ui, `ObjectView.storedRowParity-10380` and `ViewPreview.foldedOnRead-25923` render rows folded with `applyConversionsToStoredItem` and pin that an unfolded bag renders nothing. - The test fixtures that mounted a bag (79 by the seat's count in the ruling) moved to the top-level KIND form, not loosened. Tests that pinned bag behaviour now pin that the bag is refused or reaches nothing, each beside a CONTROL with the block. ## Census TypeScript AST + JSON + YAML walk of every object literal: views carrying a top-level `options` bag with a kind key. | | sites | where | |:--|--:|:--| | `origin/main` 4b8f8cd (19,856 files) | 158 | 157 tests + 1 the object page composer, `objectViewListRenderer.tsx` | | this branch (21,425 files) | 109 | 106 tests that pin the refusal, the fold or the inversion + 3 in `packages/spec/src/conversions/registry.ts` (the fold's own doc and fixtures) | | stored (examples, apps, the console's shipped metadata, ui/examples, seeds, platform-objects, fixtures) | 0 | before and after | Not reachable, so not counted as zero: hotcrm metadata, tenant `sys_metadata` rows, cloud. ## Acceptance notes - `chart` folds whole: the top-level `chart` block replaced the bag outright before this change, so a bag beside a block is dropped, not merged. - Kanban title floor: after the fold the object page and the interface page still differ on a kanban card title with no `titleField`. The object page floors `titleField: 'name'` in `kanbanViewOptions` (pinned by `kanbanLane-8193`); the interface page sends none. This is pre-existing and `ObjectView.storedRowParity-10380` pins it as observed. - The D3 entries `18.view-item-options-bag-refused.ts` and `18.view-overlay-options-bag-judged.ts` are left unedited. They describe the earlier view-item refusal and the earlier judged overlay; this PR adds `18.ui-list-view-options-bag-folded.ts` beside them. - Overlap, rechecked at push time: #27031 and #27013 (both merged) touched `ListView.tsx`, `ListView.test.tsx` and `objectViewListRenderer.tsx`; both are in this head's main merge, which was clean, and plugin-list, types, spec and metadata-protocol were rerun after it. #26966 (open) adds its own conversion to `registry.ts`; this PR's fold is registered at order 78, so the one that lands second takes the next free order. - Size: `dispatch-gates` counts 5,341 changed lines over 88 files (+2,704 / -2,637), over the 3,000-line human-merge threshold. Most of it is test fixtures moving from the bag to the block. - Test-only package entries (`patch`): console, rest, metadata-protocol, lint. - Left on #25923 after this PR (both checked on this head): work item 2 landed in #26019 except dropping `FormField`'s `[key: string]: any` index signature (`ui/packages/types/src/form.ts`), which ruling 6412606977 deferred because it breaks components `form.tsx`, plugin-form and plugin-grid; and the `immutable` follow-up from 6448239783: plugin-form's `normalizeSectionField` (`sectionFields.ts:417`) copies `immutable`, but no form renderer reads it, so an edit-mode form leaves an immutable field editable. Work item 3 is complete with #27026 and this PR. ## Verification Head `3f56e4cfe` (main `b322b5da6` merged in). Every run below was under os-verify-lock. <details><summary>ui tests</summary> - Full ui suite on the branch before the last main merge, in three batches with `--maxWorkers=3` (a single `vitest run` hit the container's memory limit at 1h49m and was killed, so it measured nothing): app-shell `Test Files 1826 passed | 1 skipped` · `Tests 16795 passed | 9 skipped`; components, plugin-list, plugin-view, plugin-map, types, core, react, plugin-grid, plugin-detail `Test Files 2041 passed | 2 skipped` · `Tests 28433 passed | 69 skipped`; the other packages, apps, scripts, eslint-rules and examples `Test Files 1843 passed | 4 skipped` · `Tests 25842 passed | 291 skipped`. Each batch exit 0. - After merging main `b322b5da6` (#27013 touched `ListView.tsx` and `ListView.test.tsx`): plugin-list + types `Test Files 560 passed (560)` · `Tests 12306 passed`, exit 0. </details> <details><summary>spec and server packages</summary> - `pnpm --filter @objectstack/spec test` after the last merge: `Test Files 741 passed (741)` · `Tests 22244 passed | 1 todo`, exit 0. `typecheck`: exit 0. - `pnpm --filter @objectstack/spec check:generated` after the spec closure build: all 14 generated artifacts up to date, exit 0. - `@objectstack/metadata-protocol` after the last merge and its closure build: `Test Files 277 passed | 4 skipped` · `Tests 21752 passed | 26 skipped`, exit 0. (Run before the closure build, one test from #26937, `runtime-authoring-gate.email-template-writes`, failed on a stale `@objectstack/lint` dist; it passes once that dist is built.) - `@objectstack/rest` `Test Files 332 passed` · `Tests 6552 passed | 326 skipped`; `@objectstack/lint` `Test Files 170 passed` · `Tests 7516 passed`. Both exit 0. </details> <details><summary>type-check and lint</summary> - `type-check` for types, plugin-list, plugin-view, plugin-map, app-shell and console: exit 0, after building the app-shell and console dependency closures (stale dists from main's #26681 had red them first). plugin-list type-check rerun after the last merge: exit 0. - `lint` for the same six packages: 0 errors. </details> <details><summary>changeset checks</summary> - `check-changeset-no-major.mjs --base origin/main` with this body as the event: exit 0; it reads `Clause-②: no (narrowing)` and `Metadata-protocol: narrowed` against the `minor` grade on `@objectstack/spec`. - `check-adr-0087-registration` (registers `list-view-options-bag-folded`, `ui-list-view-options-bag-folded`), `check-empty-changeset`, `check:changeset-fixed`, `check:nul-bytes`: exit 0. </details> <details><summary>dispatch gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 100 commands, each run with its exit code recorded. - `--ran`: 100 derived, 99 run, 1 NOT-MEASURED, 0 UNRUN. All 99 measured exit 0. - NOT-MEASURED: `pnpm check:dual-build-cjs-loads` exit 3 (its prerequisite is a full `pnpm build`). CI runs it. - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: OK, exit 0. </details> <details><summary>dogfood battery</summary> - Head `3f56e4cfe` (after the `b322b5da6` merge and the round-1 fixes): closure `pnpm --filter '@objectstack/dogfood^...' build` exit 0, then `pnpm --filter @objectstack/dogfood test`: `Test Files 348 passed | 3 skipped (351)` · `Tests 2804 passed | 20 skipped (2824)`, exit 0. - Earlier reading, on the `07d18e692` merge: `Test Files 348 passed | 3 skipped (351)` · `Tests 2803 passed | 20 skipped (2823)`, exit 0. </details> <details><summary>review round 1 (contract review 6467465885 on ef9b178)</summary> - Build & E2E UI: `sdui.manifest.json` still listed the `list-view` `options` input. `node scripts/gen-sdui-manifest-node.mjs` drops that row (5 lines, the same diff CI printed) and re-records `scripts/sdui-manifest.record.json`; `node scripts/check-sdui-manifest.mjs` exit 0. - Test Core 2/8: `scripts/conversions-major18-merge.test.ts` rule 2 wants a conversion added after the rule defined directly above the next entry's definition. Reproduced locally (3 failed of 12, naming `listViewOptionsBagFolded` and `listViewSortStringClauseToArray`). The fold and its `LIST_VIEW_OPTIONS_BAG_KINDS` constant now sit directly above `listViewSortStringClauseToArray`: 12 passed. Spec repo project `Test Files 81 passed` · `Tests 1324 passed`; spec local conversions, migrations and ui `Test Files 167 passed` · `Tests 6308 passed`. - Test Core 6/8: the D3 entry's printed `reason` cited `objectui#10380` and `#25923`. Reproduced locally with the old text and a spec build: `migrate-meta-engine-guidance.test.ts` fails with "ui-list-view-options-bag-folded: the printed guidance cites a tracker id: expected '#10380' to be undefined". Rephrased without ids: 3 passed. Full `@objectstack/cli` test `Test Files 521 passed | 1 skipped` · `Tests 6484 passed | 9 skipped`, exit 0; `check:generated` exit 0. - Changeset: the "renders as before" sentence is qualified. A stored bag folds onto the top-level blocks, which on the object page pass through the relay's defaults (the kanban `titleField: 'name'` floor, for one), so a folded row can draw differently where its bag left such a key unset; 0 reachable rows, pinned by `ObjectView.storedRowParity-10380`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzevL8Q6rTfGz2XHXGT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ebfadb0 commit 8eeb3b3

87 files changed

Lines changed: 1877 additions & 1816 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@object-ui/types': minor
4+
'@object-ui/plugin-list': minor
5+
'@object-ui/app-shell': minor
6+
'@object-ui/plugin-view': patch
7+
'@object-ui/plugin-map': patch
8+
'@object-ui/console': patch
9+
'@objectstack/rest': patch
10+
'@objectstack/metadata-protocol': patch
11+
'@objectstack/lint': patch
12+
---
13+
14+
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)
15+
16+
Clause-②: no (narrowing)
17+
Metadata-protocol: narrowed
18+
19+
<!-- adr-0087: registered list-view-options-bag-folded, ui-list-view-options-bag-folded -->
20+
21+
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.
22+
23+
- **`@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.
24+
- **`@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`.
25+
- **`@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.
26+
- **`@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.
27+
- **`@object-ui/plugin-view`**: the host list schema carries the active view's blocks at the top level instead of under `options`.
28+
- **`@object-ui/plugin-map`**: two development warnings name the view's `map` block instead of `options.map`.
29+
- **`@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.
30+
31+
**BREAKING** for a list view body, a page `list-view` node or a `ListView` host that writes `options`.
32+
33+
- **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.
34+
- **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.
35+
- **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.

‎content/docs/ui/components/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ regenerates from the renderer registry. Regenerate it with
8787
| [`ins`](/docs/ui/components/html#ins) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
8888
| [`label`](/docs/ui/components/html#label) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
8989
| [`li`](/docs/ui/components/html#li) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
90-
| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 42 | [Plugin Blocks](/docs/ui/components/plugins) |
90+
| [`list-view`](/docs/ui/components/plugins#list-view) | `plugin-list` | 41 | [Plugin Blocks](/docs/ui/components/plugins) |
9191
| [`main`](/docs/ui/components/html#main) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
9292
| [`mark`](/docs/ui/components/html#mark) | `ui` | 2 | [HTML Elements](/docs/ui/components/html) |
9393
| [`markdown`](/docs/ui/components/plugins#markdown) | `plugin-markdown` | 2 | [Plugin Blocks](/docs/ui/components/plugins) |

‎content/docs/ui/components/plugins.mdx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,6 @@ Namespace `plugin-list`.
313313
| `rowActions` | `array` of `string` | | Names of the actions offered on each row's menu. |
314314
| `bulkActions` | `array` of `string` | | Names of the actions offered once rows are selected. Needs a multi-row `selection`. |
315315
| `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. |
316-
| `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. |
317316
| `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. |
318317
| `calendar` | `object` | | The calendar, `{ startDateField, endDateField, titleField, colorField, allDayField }`. |
319318
| `gantt` | `object` | | The gantt chart, `{ startDateField, endDateField, titleField, progressField, dependenciesField }`. Both date fields are needed to draw it. |

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 217 | 208 | 4 | 0 | 5 |
24+
| `ui/` | 216 | 207 | 4 | 0 | 5 |
2525

2626
## `ui/` — sites
2727

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

5151
## `ui/` — open
5252

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

57-
**5 strip of 217**, in 4 file(s).
57+
**5 strip of 216**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
6262
| `app.zod.ts` | 1 | 19 |
63-
| `view.zod.ts` | 2 | 59 |
63+
| `view.zod.ts` | 2 | 58 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **5** | **217** |
65+
| **total** | **5** | **216** |
6666

6767
| Bucket | Sites |
6868
|---|---|

‎packages/lint/src/validate-component-props-list-view-22444.test.ts‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
* a bag holding `kanban.groupField` or `options.grid` — refused at every list
99
* view door — drew no finding at all (the census on the card measured 0). The
1010
* row needs no dispatch change here: the rule reads `ComponentPropsMap` by
11-
* type. The pins are the ruling's list, at this door: `kanban.groupField` and
12-
* `options.grid` flagged naming the key; contract keys only pass; the retired
13-
* `objectName` / `viewType` flagged with the react tier's prescription. The
11+
* type. The pins are the ruling's list, at this door: `kanban.groupField`
12+
* flagged naming the key; an `options` bag flagged as the retired key it is
13+
* since #25923 (the list overlay's tombstone, with its prescription); contract
14+
* keys only pass; the retired `objectName` / `viewType` flagged with the react
15+
* tier's prescription. The
1416
* schema half is pinned in `@objectstack/spec`
1517
* (`ui/component-list-view-row.test.ts`).
1618
*
@@ -21,7 +23,7 @@ import { REACT_RETIRED_OVERLAY_PROPS } from '@objectstack/spec/ui';
2123
import { describe, expect, it } from 'vitest';
2224

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

2628
type AnyRec = Record<string, unknown>;
2729

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

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

5357
it.each([
@@ -75,7 +79,7 @@ describe('a page `list-view` node is judged as a list view (#22444)', () => {
7579
expect(mine.map((f) => [f.rule, f.path]).sort()).toEqual(
7680
[
7781
[COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.objectName`],
78-
[COMPONENT_PROPS_UNKNOWN_KEY, `${AT}.properties.options.grid`],
82+
[COMPONENT_PROPS_INVALID, `${AT}.properties.options`],
7983
].sort(),
8084
);
8185
});

‎packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts‎

Lines changed: 26 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -383,22 +383,22 @@ describe('graftFoldedFormSections — structural safety, no save involved', () =
383383
});
384384

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

421-
it('a direct and an `options`-wrapped out-of-contract key get the same refusal, and nothing is stored', async () => {
422-
const direct = await refusal(flatList({ timeline: TIMELINE }));
423-
const wrapped = await refusal(flatList({ options: { timeline: TIMELINE } }));
424-
for (const { err, rowsWritten } of [direct, wrapped]) {
425-
expect(err?.code).toBe('INVALID_METADATA');
426-
expect(err?.status).toBe(422);
427-
expect(rowsWritten).toBe(0);
428-
}
429-
const hit = (err: any, path: string) =>
430-
(err.issues as Array<{ path: string; code?: string; message: string }>)
431-
.find((i) => i.path === path && i.code === 'unrecognized_keys');
432-
const directHit = hit(direct.err, 'timeline');
433-
const wrappedHit = hit(wrapped.err, 'options.timeline');
434-
expect(directHit, JSON.stringify(direct.err.issues)).toBeDefined();
435-
expect(wrappedHit, JSON.stringify(wrapped.err.issues)).toBeDefined();
436-
// Refused by NAME, on the same surface text as the direct spelling.
437-
expect(wrappedHit!.message).toBe(directHit!.message);
438-
expect(wrappedHit!.message).toContain('`metaFields`');
439-
});
440-
441-
it('an unknown key in the bag itself is refused by name, not dropped', async () => {
442-
const { err, rowsWritten } = await refusal(flatList({ timeline: { startDateField: 'created_at', titleField: 'name' }, options: { foo: 1 } }));
421+
it.each([
422+
['a legal `options.timeline` block', { options: { timeline: TIMELINE } }],
423+
['the legacy `options.map` bag objectui pinned', { type: 'map', options: { map: MAP } }],
424+
['a bag beside the same top-level block', { timeline: TIMELINE, options: { timeline: { titleField: 'subject' } } }],
425+
])('%s: 422 at `options` with the move-to-the-top-level prescription, and nothing is stored', async (_label, extra) => {
426+
const { err, rowsWritten } = await refusal(flatList(extra));
443427
expect(err?.code).toBe('INVALID_METADATA');
444428
expect(err?.status).toBe(422);
445429
expect(rowsWritten).toBe(0);
446-
expect((err.issues as Array<{ path: string; message: string }>).some((i) => i.path === 'options' && i.message.includes('`foo`')))
447-
.toBe(true);
430+
const hit = (err.issues as Array<{ path: string; message: string }>).find((i) => i.path === 'options');
431+
expect(hit, JSON.stringify(err.issues)).toBeDefined();
432+
expect(hit!.message).toMatch(/^A list view's `options` bag was removed in @objectstack\/spec 18\.3\.0/);
433+
expect(hit!.message).toContain('Move each `options.KIND` block to `KIND`');
448434
});
449435

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

‎packages/metadata-protocol/src/protocol.stored-conversions.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,41 @@ describe('getMetaItem — single stored read is canonical (#3903)', () => {
239239
});
240240
});
241241

242+
// [#25923] A protocol-18 list view row written while the overlay still took
243+
// the legacy `options` bag (17.5.0 to 18.2.0 judged it per kind and stored it
244+
// as sent). The bag is retired now; `list-view-options-bag-folded` is
245+
// `retiredFromLoadPath`, so only this stored seam replays it.
246+
const legacyBagViewRow = {
247+
type: 'view',
248+
name: 'showcase_task.work_map',
249+
metadata: {
250+
name: 'showcase_task.work_map',
251+
object: 'showcase_task',
252+
viewKind: 'list',
253+
label: 'Work Map',
254+
type: 'map',
255+
columns: ['title', 'location'],
256+
map: { locationField: 'location' },
257+
options: { map: { locationField: 'address', titleField: 'legacy_title' } },
258+
},
259+
};
260+
261+
describe('[#25923] a stored list view\'s `options` bag is served folded onto the top-level block', () => {
262+
it('the single and the list read move `options.KIND` to `KIND`, the top-level value winning per key', async () => {
263+
const { engine, rows } = makeStubEngine([legacyBagViewRow]);
264+
const protocol = new ObjectStackProtocolImplementation(engine);
265+
const single: any = await protocol.getMetaItem({ type: 'view', name: 'showcase_task.work_map' });
266+
const listed = ((await protocol.getMetaItems({ type: 'view' })).items as any[])
267+
.find((i) => i.name === 'showcase_task.work_map');
268+
for (const item of [single.item, listed]) {
269+
expect(item.map).toEqual({ locationField: 'location', titleField: 'legacy_title' });
270+
expect('options' in item).toBe(false);
271+
}
272+
// The row at rest is not rewritten by the reads.
273+
expect(JSON.parse(rows[0]!.metadata).options).toEqual(legacyBagViewRow.metadata.options);
274+
});
275+
});
276+
242277
describe('loadMetaFromDb — boot hydration converts, diagnoses, never drops (#3903)', () => {
243278
it('registers the CONVERTED body and reports invalid: 0 for chain-owned history', async () => {
244279
const { engine, registered } = makeStubEngine([legacyObjectRow, legacyActionRow]);

0 commit comments

Comments
 (0)