Skip to content

Commit d4f5232

Browse files
os-billclaude
andauthored
feat(spec)!: retire the type: 'page' list-view mount and its pageName binding (#17298)
* feat(spec)!: retire the `type: 'page'` list-view mount and its `pageName` binding A list view could declare `type: 'page'` and name a published page in `pageName`, delegating its whole rendering to the page renderer. Only the spec half of that was ever built (#13216 direction 1, PR #13372). No renderer ever routed the member — objectui's list-view switch shares its `default:` arm with `case 'grid'`, and `isListViewVisualization('page')` is false — so a page view drew an empty table where the page belonged, and the three parse refusals policing the binding (`checkListViewPageMount`) policed a mount that never mounted anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision batch #107 item 1. - `pageName` becomes a `retiredKey()` tombstone on both list-view doors. - `'page'` leaves the `type` enum; the enum's own error map carries the prescription, keyed on `issue.input` (the `exportOptions` 'pdf' precedent — an enum-VALUE narrowing has no tombstone to hang one on). - `checkListViewPageMount`, its three refusal messages, the `validateCrossReferences` page branch, `@objectstack/lint`'s `validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`, and `view.form.ts`'s `page` section are removed with the mount. - `RuntimeStackContext.pages` and the `page` row of `CLOSURE_CONTEXT_KEY_BY_TYPE` leave with the only crossed rule that read them: a view publish no longer gathers a live page universe. - ADR-0087: D2 conversion `view-page-mount-removed` (protocol 18) strips both keys from stored rows and `os migrate meta --from 17` output; `type` is stripped rather than rewritten, since the schema defaults it to `grid` — exactly what the row already rendered. The surviving page mount is the app navigation item (`PageNavItem.pageName`), untouched. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * chore(i18n): regenerate metadata-form bundles after the page-mount retirement `view.form.ts`'s `page` section and its `pageName` input are gone (#17063), so the extracted metadata-form labels drop with them: the `page` section label/description and the `pageName` label/helpText, across en/es-ES/ja-JP/zh-CN plus the three source-hash bundles. Pure deletion — merge mode adds and translates, it does not remove, so this is `check-i18n-bundles.mjs --write` rewriting the default locale from source and dropping the orphaned keys. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * chore(spec): regenerate the two os-regen artifacts the merge driver dropped `packages/spec/api-surface/ui.json` and `packages/spec/export-origins/ui.json` are routed to `merge=os-regen`, and both sides of the merge had edited them, so the driver merged them with exit 0 while keeping one side. The dropped side was main's: #17257 added four `*Parsed` type exports on the `ui` entry point (`ElementDataSourceParsed`, `ObjectCalendarPropsParsed`, `ObjectKanbanPropsParsed`, `ObjectMetricPropsParsed`), and the merge commit's copy of both artifacts carried none of them. Regenerated from the committed merge — `pnpm --filter @objectstack/spec build` then the gen chain — so the content is re-derived from the merged source rather than hand-reconciled. Both files gain exactly those four rows and nothing else; every other generated artifact this branch touches (migration registry, authorable surface, spec-changes, upgrade guide, liveness counts, react-blocks, the reference docs) regenerated byte-identical to the merged tree. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com> * chore(spec): regenerate view.mdx on the merged tree and repair a renamed test helper Discharges the os-regen deferral recorded by the merge commit. content/docs/references/ui/view.mdx — re-derived with `gen:docs` from the merged sources on a known-good base, rather than accepting what the merge driver left. The driver had exited 0 while keeping the branch's blob verbatim and silently dropping main's side; the merge commit took main's side per `scripts/pm/os-regen-merge.sh` step 2, and this regeneration re-derives the retirement's own rows on top. Both sides are present in the result. packages/spec/src/ui/view.test.ts — main added a third `describe.each` site using `viewDoorsCarryingPageMountCheck`, which this branch had renamed to `viewDoorsCarryingObjectLevelChecks`. Both edits merged without a textual conflict and left a dangling identifier; the rename is propagated to main's new block. No behaviour change on either side. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc2bf01 commit d4f5232

48 files changed

Lines changed: 720 additions & 1032 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/metadata-protocol': minor
5+
---
6+
7+
**BREAKING** — retire the `type: 'page'` list-view mount and its `pageName` binding.
8+
9+
A list view could declare `type: 'page'` and name a published page in `pageName`,
10+
and the view was to render nothing of its own and delegate to the page renderer.
11+
Only the spec half of that was ever built. **No renderer ever routed the member**:
12+
objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page
13+
view has always drawn an empty table where the page was supposed to be, and the
14+
three parse refusals that policed the binding policed a mount that never mounted
15+
anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09.
16+
17+
## FROM → TO
18+
19+
| you wrote (17.4 and earlier) | write instead |
20+
| --- | --- |
21+
| `{ type: 'page', pageName: 'sales_home', columns: [] }` on a list view | nothing on the view. Delete it, and reach the page from the app's `navigation`: `{ id: 'nav_sales_home', type: 'page', pageName: 'sales_home', label: 'Sales' }` |
22+
| `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone |
23+
| a list view that wanted rows | pick a row-drawing `type``grid` and its siblings, all unchanged |
24+
25+
**The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put
26+
the page behind an app navigation item, which is a different key on a different
27+
surface (`PageNavItem.pageName`) and is the page mount that has always rendered.
28+
29+
`os migrate meta --from 17` lists the mechanical edits for existing sources; apply
30+
them by hand.
31+
32+
## The retirement kit
33+
34+
- **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and
35+
`ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse
36+
raises the prescription rather than a bare unrecognized-key report.
37+
- **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on
38+
(the def survives, one value lighter, and the four generated-surface ratchets are
39+
blind to that by construction). The `type` enum's own `error` map carries it,
40+
keyed on `issue.input` so only the value that used to be legal gets the
41+
"was removed" message; every other invalid `type` keeps zod's default text.
42+
- **`checkListViewPageMount`** — the exported object-level refinement existed only
43+
to police this mount, so it is removed with it, along with its three refusal
44+
messages. A downstream mirror that re-attached it (the reason it was exported)
45+
should drop the `.superRefine` line; the compiler delivers this one. It held no
46+
`ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes.
47+
- **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the
48+
`os validate` and publish-gate rule that resolved a mount against `stack.pages`.
49+
Removed: there is no reference left to resolve. Its nav twin
50+
(`validateNavTargetRefs`, on the app navigation item) is **untouched**.
51+
- **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of
52+
`CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page
53+
universe joined the per-write snapshot for that one rule, and leaves with it. A
54+
`PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a
55+
collection nothing consults. Hosts calling `runRuntimeAuthoringRules` /
56+
`evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key.
57+
- **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's
58+
`pageName` against `stack.pages` is gone. The surviving three page references in
59+
that function (an app nav item's `pageName`, a modal action's `target` at two
60+
rungs) keep their own policy.
61+
- **The metadata form**`view.form.ts`'s `page` section, whose one input was
62+
`pageName`, is removed. A form input for an unwritable key is the false-compliant
63+
UI half of a retirement.
64+
65+
## What an operator with a STORED page view sees
66+
67+
A `sys_metadata` `view` row written before this release can carry `type: 'page'` and
68+
a `pageName`. Nothing breaks at read: the ADR-0087 conversion
69+
`view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys,
70+
so the row is served canonical. `type` is **stripped, not rewritten** — it defaults
71+
to `grid` in the schema, so the row lands on exactly what it already rendered
72+
without the platform guessing a view type.
73+
74+
The strip is announced once per row per process, on whichever seam served it.
75+
Grep for `carries a pre-protocol shape` — there are **three** emitters, one per
76+
rehydration seam, and they differ:
77+
78+
- `[DatabaseLoader] stored view/<name> carries a pre-protocol shape; <notice>`
79+
- `[ObjectQLPlugin] stored view/<name> carries a pre-protocol shape; <notice>`
80+
- `[Protocol] stored view/<name> carries a pre-protocol shape; <notice> The row
81+
itself is unchanged — re-save it (Studio edit -> save, or run
82+
"os migrate meta --stored --apply") to persist the canonical shape.`
83+
84+
`os migrate meta --from 17` lists the same edits for authored sources;
85+
`os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and
86+
the next save through `PUT /api/v1/meta/view` heals one row the way it heals any
87+
pre-protocol shape.
88+
89+
⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does
90+
**not** reach `objects[].listViews.*`, which no conversion in the registry reaches.
91+
An object body still carrying a page mount is refused at its own door with the
92+
prescription rather than converted. Measured population for both at the ruling:
93+
**zero** authored `type: 'page'` list views in this repository or any consuming app
94+
the seats can read — the in-tree `type: 'page'` hits are all app nav items.
95+
96+
<!-- adr-0087: registered view-page-mount-removed -->

content/docs/references/api/metadata.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -808,7 +808,7 @@ Metadata query with filtering, sorting, and pagination
808808
| **highlightFields** | `string[]` | optional | [ADR-0085] Ordered most-important fields; first entry wins where only one fits. Drives default columns, cards, previews, detail highlight strip. Renamed from compactLayout. |
809809
| **stageField** | `string \| false` | optional | [ADR-0085] Lifecycle stage field (linear/ordered), or false to declare the status field non-linear and suppress stage heuristics. Absent = heuristic detection allowed. |
810810
| **editMode** | `Enum<'modal' \| 'page'>` | optional | Edit-interaction intent for records of this object: 'modal' opens the edit form as a dialog over the current view; 'page' navigates to a dedicated full-page edit route. Absent = the renderer picks its own default (objectui defaults to modal). Cross-renderer intent, not pixel styling (family). |
811-
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
811+
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Built-in named list views (segmented tabs) shipped with the object schema — "views" mode, dropdown userFilters allowed, no page-only tabs (ADR-0047) |
812812
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
813813
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
814814
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |

content/docs/references/api/protocol.mdx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1609,9 +1609,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
16091609
| **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. |
16101610
| **label** | `string \| Record<string, string>` | optional | Human-readable label shown in metadata lists. |
16111611
| **object** | `string` | optional | Object this container binds to — how a stack-level `views: [...]` entry says which object its views belong to; read by `getViewsByObject()` / `GET /meta/view?object=`. |
1612-
| **list** | `{ name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >; data?: object \| … +3 more; … }` | optional | |
1612+
| **list** | `{ name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | |
16131613
| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | |
1614-
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) |
1614+
| **listViews** | `Record<string, { name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }>` | optional | Additional named list views (views mode — dropdown userFilters allowed, no tabs; ADR-0047) |
16151615
| **formViews** | `Record<string, { type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }>` | optional | Additional named form views |
16161616
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. |
16171617
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
@@ -1628,7 +1628,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
16281628
| :--- | :--- | :--- | :--- |
16291629
| **name** | `string` | optional | Internal view name (lowercase snake_case) |
16301630
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
1631-
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >` | optional (default: `"grid"`) | |
1631+
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
16321632
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
16331633
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` || Fields to display as columns |
16341634
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
@@ -1648,7 +1648,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
16481648
| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration |
16491649
| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout |
16501650
| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
1651-
| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. |
1651+
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '<page_name>' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
16521652
| **description** | `string \| Record<string, string>` | optional | View description for documentation/tooltips |
16531653
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
16541654
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
@@ -1713,7 +1713,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
17131713
| :--- | :--- | :--- | :--- |
17141714
| **name** | `string` | optional | Internal view name (lowercase snake_case) |
17151715
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
1716-
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >` | optional (default: `"grid"`) | |
1716+
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
17171717
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
17181718
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` || Fields to display as columns |
17191719
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
@@ -1733,7 +1733,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
17331733
| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration |
17341734
| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout |
17351735
| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
1736-
| **pageName** | `string` | optional | Published page this view mounts — required when `type: 'page'`, and refused on every other view type. Rendering is delegated to the existing page renderer; the page keeps its own `assignedProfiles` audience. |
1736+
| **pageName** | `never` | optional | [REMOVED] `view.pageName` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the page a `type: 'page'` view was to mount, and that mount was never built: no renderer read the key, so the named page was never reached and the view drew an empty grid. Delete the key; to put a published page in front of users, give the app a navigation item — `{ type: 'page', pageName: '<page_name>' }` under the app's `navigation` — which is a different key on a different surface and is the page mount that has always rendered. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
17371737
| **description** | `string \| Record<string, string>` | optional | View description for documentation/tooltips |
17381738
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
17391739
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |

0 commit comments

Comments
 (0)