Skip to content
Draft
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
96 changes: 96 additions & 0 deletions .changeset/retire-list-view-page-mount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/metadata-protocol': minor
---

**BREAKING** — 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`,
and the view was to render nothing of its own and delegate to the page renderer.
Only the spec half of that was ever built. **No renderer ever routed the member**:
objectui's list-view switch shares its `default:` arm with `case 'grid'`, so a page
view has always drawn an empty table where the page was supposed to be, and the
three parse refusals that policed the binding policed a mount that never mounted
anything. ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09.

## FROM → TO

| you wrote (17.4 and earlier) | write instead |
| --- | --- |
| `{ 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' }` |
| `pageName` beside any other list-view `type` | delete the key — it was refused already, and is now a tombstone |
| a list view that wanted rows | pick a row-drawing `type` — `grid` and its siblings, all unchanged |

**The one-line fix:** delete `type: 'page'` and `pageName` from the list view; put
the page behind an app navigation item, which is a different key on a different
surface (`PageNavItem.pageName`) and is the page mount that has always rendered.

`os migrate meta --from 17` lists the mechanical edits for existing sources; apply
them by hand.

## The retirement kit

- **`pageName`** — a `retiredKey()` tombstone on `ListViewSchema` and
`ObjectListViewSchema`. `tsc` types the key `never`, and a value reaching a parse
raises the prescription rather than a bare unrecognized-key report.
- **`'page'`** — an enum VALUE, so there is no tombstone to hang a prescription on
(the def survives, one value lighter, and the four generated-surface ratchets are
blind to that by construction). The `type` enum's own `error` map carries it,
keyed on `issue.input` so only the value that used to be legal gets the
"was removed" message; every other invalid `type` keeps zod's default text.
- **`checkListViewPageMount`** — the exported object-level refinement existed only
to police this mount, so it is removed with it, along with its three refusal
messages. A downstream mirror that re-attached it (the reason it was exported)
should drop the `.superRefine` line; the compiler delivers this one. It held no
`ERROR_CODE_LEDGER` row — the three refusals were message constants, not codes.
- **`validateViewPageRefs` / `VIEW_PAGE_UNRESOLVED`** (`@objectstack/lint`) — the
`os validate` and publish-gate rule that resolved a mount against `stack.pages`.
Removed: there is no reference left to resolve. Its nav twin
(`validateNavTargetRefs`, on the app navigation item) is **untouched**.
- **`RuntimeStackContext.pages`** (`@objectstack/lint`) and the `page` row of
`CLOSURE_CONTEXT_KEY_BY_TYPE` (`@objectstack/metadata-protocol`) — the live page
universe joined the per-write snapshot for that one rule, and leaves with it. A
`PUT /api/v1/meta/view` publish no longer pays a `sys_metadata` round trip for a
collection nothing consults. Hosts calling `runRuntimeAuthoringRules` /
`evaluateRuntimeAuthoringGate` with an explicit `context.pages` drop that key.
- **`defineStack`** — the `validateCrossReferences` branch that resolved a mount's
`pageName` against `stack.pages` is gone. The surviving three page references in
that function (an app nav item's `pageName`, a modal action's `target` at two
rungs) keep their own policy.
- **The metadata form** — `view.form.ts`'s `page` section, whose one input was
`pageName`, is removed. A form input for an unwritable key is the false-compliant
UI half of a retirement.

## What an operator with a STORED page view sees

A `sys_metadata` `view` row written before this release can carry `type: 'page'` and
a `pageName`. Nothing breaks at read: the ADR-0087 conversion
`view-page-mount-removed` (protocol 18) replays on rehydration and strips both keys,
so the row is served canonical. `type` is **stripped, not rewritten** — it defaults
to `grid` in the schema, so the row lands on exactly what it already rendered
without the platform guessing a view type.

The strip is announced once per row per process, on whichever seam served it.
Grep for `carries a pre-protocol shape` — there are **three** emitters, one per
rehydration seam, and they differ:

- `[DatabaseLoader] stored view/<name> carries a pre-protocol shape; <notice>`
- `[ObjectQLPlugin] stored view/<name> carries a pre-protocol shape; <notice>`
- `[Protocol] stored view/<name> carries a pre-protocol shape; <notice> The row
itself is unchanged — re-save it (Studio edit -> save, or run
"os migrate meta --stored --apply") to persist the canonical shape.`

`os migrate meta --from 17` lists the same edits for authored sources;
`os migrate meta --stored --apply` rewrites the stored rows so the warn stops, and
the next save through `PUT /api/v1/meta/view` heals one row the way it heals any
pre-protocol shape.

⚠️ The conversion walks `stack.views[]` in all three persisted spellings; it does
**not** reach `objects[].listViews.*`, which no conversion in the registry reaches.
An object body still carrying a page mount is refused at its own door with the
prescription rather than converted. Measured population for both at the ruling:
**zero** authored `type: 'page'` list views in this repository or any consuming app
the seats can read — the in-tree `type: 'page'` hits are all app nav items.

<!-- adr-0087: registered view-page-mount-removed -->
2 changes: 1 addition & 1 deletion content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -808,7 +808,7 @@ Metadata query with filtering, sorting, and pagination
| **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. |
| **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. |
| **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). |
| **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) |
| **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) |
| **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. |
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
| **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). |
Expand Down
12 changes: 6 additions & 6 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1609,9 +1609,9 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. |
| **label** | `string \| Record<string, string>` | optional | Human-readable label shown in metadata lists. |
| **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=`. |
| **list** | `{ name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >; data?: object \| … +3 more; … }` | optional | |
| **list** | `{ name?: string; label?: string \| Record<string, string>; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | optional | |
| **form** | `{ type?: Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>; layout?: Enum<'vertical' \| 'horizontal' \| 'inline' \| 'grid'>; columns?: integer; title?: string; … }` | optional | |
| **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) |
| **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) |
| **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 |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this view. |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand All @@ -1628,7 +1628,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Internal view name (lowercase snake_case) |
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >` | optional (default: `"grid"`) | |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
| **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) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
Expand All @@ -1648,7 +1648,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration |
| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout |
| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| **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. |
| **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. |
| **description** | `string \| Record<string, string>` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
Expand Down Expand Up @@ -1713,7 +1713,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| :--- | :--- | :--- | :--- |
| **name** | `string` | optional | Internal view name (lowercase snake_case) |
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| >` | optional (default: `"grid"`) | |
| **type** | `Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>` | optional (default: `"grid"`) | |
| **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) |
| **columns** | `string[] \| { field: string; label?: string \| Record<string, string>; width?: number; align?: Enum<'left' \| 'center' \| 'right'>; … }[]` | ✅ | Fields to display as columns |
| **filter** | `{ field: string; operator?: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Filter criteria (JSON Rules) |
Expand All @@ -1733,7 +1733,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **chart** | `{ chartType?: Enum<'bar' \| 'line' \| 'pie' \| 'area' \| 'scatter'>; dataset: string; dimensions?: string[]; values: string[] }` | optional | List chart view configuration |
| **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map configuration — applies when the view renders as a map layout |
| **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration — applies when the view renders as a tree layout |
| **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. |
| **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. |
| **description** | `string \| Record<string, string>` | optional | View description for documentation/tooltips |
| **sharing** | `{ type?: Enum<'personal' \| 'collaborative'>; lockedBy?: string }` | optional | View sharing and access configuration |
| **rowHeight** | `Enum<'compact' \| 'short' \| 'medium' \| 'tall' \| 'extra_tall'>` | optional | Row height / density setting |
Expand Down
Loading
Loading