From 684a935f00357f8b2b4c0dc4525db5a503b57cce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 04:03:50 +0000 Subject: [PATCH 1/3] feat(spec)!: retire the `type: 'page'` list-view mount and its `pageName` binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/retire-list-view-page-mount.md | 96 ++++++++ content/docs/references/api/metadata.mdx | 2 +- content/docs/references/api/protocol.mdx | 12 +- content/docs/references/data/object.mdx | 6 +- content/docs/references/system/migration.mdx | 4 +- content/docs/references/ui/view.mdx | 32 +-- .../src/authoring-rule-input-tier.test.ts | 4 +- packages/lint/src/index.ts | 2 - .../src/reference-integrity-suite.test.ts | 8 +- .../lint/src/reference-integrity-suite.ts | 28 +-- .../runtime-gate.derived-context-keys.test.ts | 8 +- .../runtime-gate.derived-name-keys.test.ts | 9 +- packages/lint/src/runtime-gate.test.ts | 13 +- packages/lint/src/runtime-gate.ts | 53 ++-- .../src/runtime-gate.view-page-refs.test.ts | 123 ---------- .../lint/src/runtime-gate.view-writes.test.ts | 16 +- .../lint/src/validate-list-view-field-refs.ts | 5 +- .../src/validate-predicate-path-refs.test.ts | 20 +- .../lint/src/validate-view-page-refs.test.ts | 151 ------------ packages/lint/src/validate-view-page-refs.ts | 177 -------------- ...tocol.runtime-gate-stored-universe.test.ts | 49 +--- packages/metadata-protocol/src/protocol.ts | 30 +-- .../src/runtime-authoring-gate.ts | 32 +-- packages/spec/api-surface/ui.json | 1 - packages/spec/authorable-surface/ui.json | 4 +- packages/spec/export-origins/ui.json | 1 - packages/spec/liveness/state-counts.md | 4 +- packages/spec/liveness/view.json | 9 +- packages/spec/src/conversions/registry.ts | 104 ++++++++ .../src/kernel/functional-completeness.ts | 17 +- .../retired-keys/18.ui__ListView__pageName.ts | 13 + .../18.ui__ObjectListView__pageName.ts | 13 + packages/spec/src/migrations/registry.ts | 38 ++- packages/spec/src/stack.test.ts | 77 ++++-- packages/spec/src/stack.zod.ts | 53 +--- .../object-refinement-check-exports.test.ts | 62 +++-- packages/spec/src/ui/view.form.ts | 26 +- packages/spec/src/ui/view.test.ts | 155 +++++++----- packages/spec/src/ui/view.zod.ts | 228 +++++++----------- skills/objectstack-ui/SKILL.md | 1 - .../objectstack-ui/references/react-blocks.md | 2 +- 41 files changed, 710 insertions(+), 978 deletions(-) create mode 100644 .changeset/retire-list-view-page-mount.md delete mode 100644 packages/lint/src/runtime-gate.view-page-refs.test.ts delete mode 100644 packages/lint/src/validate-view-page-refs.test.ts delete mode 100644 packages/lint/src/validate-view-page-refs.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__pageName.ts create mode 100644 packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__pageName.ts diff --git a/.changeset/retire-list-view-page-mount.md b/.changeset/retire-list-view-page-mount.md new file mode 100644 index 0000000000..07f1fb2c30 --- /dev/null +++ b/.changeset/retire-list-view-page-mount.md @@ -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/ carries a pre-protocol shape; ` +- `[ObjectQLPlugin] stored view/ carries a pre-protocol shape; ` +- `[Protocol] stored view/ carries a pre-protocol shape; 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. + + diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index fc08372a6a..f8a82eca56 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -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; 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; 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). | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 937726dcab..ce47202ca4 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -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` | 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; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; 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; 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; 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; 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). | @@ -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` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -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: '' }` 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` | 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 | @@ -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` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -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: '' }` 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` | 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 | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index c8e7652aa9..f76e1a4a7f 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -166,7 +166,7 @@ const result = ApiMethod.parse(data); | **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; 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; 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). | @@ -362,7 +362,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -382,7 +382,7 @@ const result = ApiMethod.parse(data); | **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: '' }` 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` | 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 | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index c0cd3bb137..29bfbc091b 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -342,7 +342,7 @@ Create a new object | **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; 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; 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). | @@ -627,7 +627,7 @@ Create a new object | **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; 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; 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). | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index da2b0cd7e6..347d5117e2 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -785,7 +785,7 @@ Map view configuration | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | 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' \| 'chart' \| 'tree' \| 'page'>` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -806,7 +806,7 @@ Map view configuration | **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: '' }` 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` | 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 | @@ -1191,7 +1191,7 @@ Tab configuration for multi-tab view interface | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | 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' \| 'chart' \| 'tree' \| 'page'>` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -1211,7 +1211,7 @@ Tab configuration for multi-tab view interface | **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: '' }` 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` | 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 | @@ -1768,9 +1768,9 @@ Tab configuration for multi-tab view interface | **name** | `string` | optional | Item name — supplied by the metadata door; for an object-scoped container it is the object name. | | **label** | `string \| Record` | 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; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | optional | | +| **list** | `{ name?: string; label?: string \| Record; 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; 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; 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; 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). | @@ -1787,7 +1787,7 @@ Tab configuration for multi-tab view interface | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -1807,7 +1807,7 @@ Tab configuration for multi-tab view interface | **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: '' }` 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` | 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 | @@ -1872,7 +1872,7 @@ Tab configuration for multi-tab view interface | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -1892,7 +1892,7 @@ Tab configuration for multi-tab view interface | **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: '' }` 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` | 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 | @@ -2088,7 +2088,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2112,7 +2112,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -2133,7 +2133,7 @@ This schema accepts one of the following structures: | **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: '' }` 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` | 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 | @@ -2261,7 +2261,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **viewKind** | `'list'` | ✅ | | -| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| …>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | +| **config** | `{ name?: string; label?: string \| Record; type?: Enum<'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'>; data?: object \| … +3 more; … }` | ✅ | List-family view configuration. | | **name** | `string` | ✅ | Globally-unique view id, `.`. | | **object** | `string` | ✅ | Bound object name — the foreign key used to aggregate views. | | **label** | `string \| Record` | optional | Display label (supports i18n). | @@ -2288,7 +2288,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Internal view name (lowercase snake_case) | | **label** | `string \| Record` | 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 }` | optional | Data source configuration (defaults to "object" provider) | | **columns** | `string[] \| { field: string; label?: string \| Record; 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) | @@ -2309,7 +2309,7 @@ This schema accepts one of the following structures: | **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: '' }` 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` | 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 | diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts index 87513e0cbb..5eee3b9bb3 100644 --- a/packages/lint/src/authoring-rule-input-tier.test.ts +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -476,7 +476,7 @@ describe('security-owd-alias reaches the rule only through the unparsed doors (# const result = runRuntimeAuthoringRules({ type: 'object', item: owdObject('full'), - context: { objects: [], permissions: [], books: [], datasets: [], pages: [] }, + context: { objects: [], permissions: [], books: [], datasets: [] }, }); expect(aliasFindings(result.errors)).toEqual(['objects.tier_owd.sharingModel']); }); @@ -494,7 +494,7 @@ describe('security-owd-alias reaches the rule only through the unparsed doors (# const result = runRuntimeAuthoringRules({ type: 'object', item: { ...owdObject('private'), name: 'tier_other' }, - context: { objects: [storedFull], permissions: [], books: [], datasets: [], pages: [] }, + context: { objects: [storedFull], permissions: [], books: [], datasets: [] }, }); expect(aliasFindings(result.errors)).toEqual([]); }); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 13589e4329..92d59a5d9e 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -414,8 +414,6 @@ export type { NavTargetRefFinding, NavTargetRefSeverity } from './validate-nav-t // [#13216] The same page reference on the OTHER surface that can carry one: a // `type: 'page'` list view's `pageName`. Advisory, for its nav twin's reason. -export { validateViewPageRefs, VIEW_PAGE_UNRESOLVED } from './validate-view-page-refs.js'; -export type { ViewPageRefFinding, ViewPageRefSeverity } from './validate-view-page-refs.js'; // [#7912] The servability question about an `object` nav target: not "does the // name resolve?" but "can the destination answer a list at all?". Gates, and diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index 7d97fc3c5b..8cd01fb3b4 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -45,10 +45,10 @@ describe('reference-integrity suite — membership', () => { 'validateDatasetReferences', 'validateNavAccess', 'validateNavTargetRefs', - // [#13216] The conscious edit this written-out list exists to force: the - // page reference on a `type: 'page'` list view, placed beside the nav - // twin that asks the identical question one surface over. - 'validateViewPageRefs', + // [#17063] `validateViewPageRefs` sat HERE, beside the nav twin that asks + // the identical question one surface over. It left with the `type: 'page'` + // list-view mount it resolved — this written-out list is exactly where a + // removal has to be argued, the same way an addition is. 'validateNavObjectServability', 'validateTranslationReferences', 'validateTranslatableSections', diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 731776e238..2d499fe0f4 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -111,7 +111,6 @@ import { validateChartBindings } from './validate-chart-bindings.js'; import { validateDatasetReferences } from './validate-dataset-references.js'; import { validateNavAccess } from './validate-nav-access.js'; import { validateNavTargetRefs } from './validate-nav-target-refs.js'; -import { validateViewPageRefs } from './validate-view-page-refs.js'; import { validateNavObjectServability } from './validate-nav-object-servability.js'; import { validateTranslationReferences } from './validate-translation-references.js'; import { validateTranslatableSections } from './validate-translatable-sections.js'; @@ -359,24 +358,15 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // `action` is deliberately absent (validateActionNameRefs owns it) and so is // `component` (an unregistered ref renders a named diagnostic, not silence). { name: 'validateNavTargetRefs', run: validateNavTargetRefs }, - // [#13216] The SAME reference — `{ type: 'page', pageName }` — one surface - // over: a `type: 'page'` list view mounting a published page. It restores the - // coverage its nav twin restores, when `defineStack`'s own check switches - // itself off, and carries the same advisory severity for the same reason (no - // curated cross-package page registry exists to tell "unresolved" from - // "provided by a package we cannot see"). - // - // The first member crossed onto `view` snapshots for something that is not a - // field-existence question, and the crossing is the point rather than a - // bonus: the mount is authored through `PUT /api/v1/meta/view` by an agent - // that never runs a CLI (#13100's measured path), so a build-time-only rule - // would never reach its author. Safe to cross ONLY because the per-write - // snapshot now carries `pages` (`RuntimeStackContext.pages`, landed with this - // member) — without that collection the member would report every legitimate - // mount as dead, which is the missing-collection false-positive channel - // `runtimeTypes` exists to keep closed. Measured both ways in - // `runtime-gate.view-page-refs.test.ts`. - { name: 'validateViewPageRefs', runtimeTypes: ['flow', 'view'], run: validateViewPageRefs }, + // [#17063] `validateViewPageRefs` stood HERE — the `{ type: 'page', pageName }` + // reference one surface over from the nav twin, on a `type: 'page'` list view. + // It was retired with the mount it resolved (ADR-0049 enforce-or-remove, + // maintainer ruling 2026-09-09 「撤」): a list view can no longer carry + // `pageName` at all, so there is no reference left to resolve. It was the only + // member whose `runtimeTypes` reached for `stack.pages`, and the live page + // universe left `RuntimeStackContext` in the same change — the widening and + // its retirement are one edit each, in the direction the runtime-gate docblock + // describes. The nav twin (`validateNavTargetRefs`, above) is untouched. // [#7912] The THIRD question about a nav entry, after "does the target // resolve?" (above) and "is it granted?" (`validateNavAccess`): can the // destination serve at all? An object's own `enable` block can make its list diff --git a/packages/lint/src/runtime-gate.derived-context-keys.test.ts b/packages/lint/src/runtime-gate.derived-context-keys.test.ts index e511483b43..a7ed62fabc 100644 --- a/packages/lint/src/runtime-gate.derived-context-keys.test.ts +++ b/packages/lint/src/runtime-gate.derived-context-keys.test.ts @@ -53,7 +53,13 @@ describe('the derived context-collection set (#13977)', () => { // The whole set, in order — including `datasets`, which the name-keyed pin // filters out and therefore cannot hold in position. A reordering of the // record `CONTEXT_STACK_KEYS` derives from lands here first. - expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets', 'pages']); + // + // [#17063] `pages` left this set with `validateViewPageRefs` and the + // `type: 'page'` view mount it resolved. The expectation MOVES rather than + // being relaxed to a subset check: naming the whole set in order is the + // only thing this pin does, so a removal has to be written here the same + // way #13216's addition was. + expect(carriedStackKeys()).toEqual(['objects', 'permissions', 'books', 'datasets']); }); it('derives the same set whatever the write is, since the write does not choose it', () => { diff --git a/packages/lint/src/runtime-gate.derived-name-keys.test.ts b/packages/lint/src/runtime-gate.derived-name-keys.test.ts index 03c201bf26..e0515810f9 100644 --- a/packages/lint/src/runtime-gate.derived-name-keys.test.ts +++ b/packages/lint/src/runtime-gate.derived-name-keys.test.ts @@ -50,12 +50,17 @@ const contextStackKeys = Object.keys( ); describe('the derived name-keyed set (#13390)', () => { - it('reproduces exactly the membership the hand list carried — four, in the same order', () => { + it('reproduces exactly the membership the hand list carried, in the same order', () => { + // [#13390] The hand list this replaced held four: `objects`, `permissions`, + // `books`, `pages`. [#17063] `pages` left the CONTEXT set with + // `validateViewPageRefs`, so the derivation drops it here too — which is + // the correct answer rather than a regression: a `page` write's snapshot + // again holds exactly one page, its own, so `pages[0]` IS that write and + // name-keying it would say nothing the index does not. expect(deriveNameKeyedStackKeys(contextStackKeys, WRITTEN_STACK_KEYS)).toEqual([ 'objects', 'permissions', 'books', - 'pages', ]); }); diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index c8633f9cb6..d9d495234b 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -253,12 +253,13 @@ describe('runtime publish gate (#4463)', () => { it('an absent context still yields empty collections, never a throw', () => { const s = buildRuntimeWriteSnapshots({ type: 'book', item: { name: 'b1' } })!; - // [#13216] `pages` joined CONTEXT_STACK_KEYS with `validateViewPageRefs` - // — the collection a `type: 'page'` view's `pageName` resolves against. - // The expectation moves rather than being relaxed: the invariant under - // test is "every context collection is present and empty", so it has to - // name the whole set or it stops measuring the shape it exists for. - expect(s.baseline).toEqual({ objects: [], permissions: [], books: [], datasets: [], pages: [] }); + // [#13216 / #17063] `pages` joined CONTEXT_STACK_KEYS with + // `validateViewPageRefs` and left with it — no runtime-crossed rule reads + // `stack.pages` any more. The expectation moves rather than being relaxed: + // the invariant under test is "every context collection is present and + // empty", so it has to name the whole set or it stops measuring the shape + // it exists for. + expect(s.baseline).toEqual({ objects: [], permissions: [], books: [], datasets: [] }); expect(s.candidate.books).toEqual([{ name: 'b1' }]); }); }); diff --git a/packages/lint/src/runtime-gate.ts b/packages/lint/src/runtime-gate.ts index 1135d44354..7f7e227b09 100644 --- a/packages/lint/src/runtime-gate.ts +++ b/packages/lint/src/runtime-gate.ts @@ -177,23 +177,6 @@ export interface RuntimeStackContext { * for (#4463 D4). */ datasets?: readonly unknown[]; - /** - * The live page declarations (stack key `pages`). - * - * [#13216] The resolution universe `validateViewPageRefs` resolves a - * `type: 'page'` list view's `pageName` against. It is carried for the - * reason `datasets` is carried and states first: without it a per-write - * `view` snapshot holds NO pages at all, so every legitimate page mount - * reads as dangling. The widening is the "one-key edit here plus a - * `CONTEXT_STACK_KEYS` entry, made when a rule that reads the collection - * actually crosses the wall" this docblock describes — the rule crossed in - * the same change, never in advance. - * - * Carrying it in BOTH differential passes also cancels page-derived findings - * for every other write type, so a stored page's pre-existing condition is - * not some unrelated write's to answer for (#4463 D4). - */ - pages?: readonly unknown[]; } /** @@ -373,7 +356,6 @@ const CONTEXT_STACK_KEY_ORDER = { permissions: true, books: true, datasets: true, - pages: true, } as const satisfies { [K in keyof RuntimeStackContext]-?: true }; /** @@ -491,8 +473,8 @@ export function buildRuntimeWriteSnapshots(args: { const baseline: AnyRec = {}; for (const key of CONTEXT_STACK_KEYS) { // [#9612] `objects` — and only `objects` — is reduced to the written - // item's package closure. The other four collections are already bounded - // by what a tenant authors (permission sets, books, datasets, pages), and the + // item's package closure. The other collections are already bounded + // by what a tenant authors (permission sets, books, datasets), and the // measured bill is entirely in what the rules walk over `objects`. // // ⭐ Narrowing here rather than at either call site is what makes this ONE @@ -611,25 +593,28 @@ export const WRITTEN_STACK_KEYS: ReadonlySet = new Set(Object.values(TYP * construction. This derivation is unchanged — it always rested on the * MEMBERSHIP of that set, and it now inherits a set the compiler keeps whole. * - * [#13216] `pages` is the measurement that made the case: adding it touched + * [#13216] `pages` was the measurement that made the case: adding it touched * FIVE spellings of this one set and only the fifth announced itself — the one * the compiler could see, and only after that accumulator was retyped as a - * mapped type. The pairing is the rule rather than a coincidence. Before the - * live page universe joined the snapshot, a `page` write's snapshot held exactly - * one page, so `pages[0]` WAS this write and name-keying it would have been - * pointless; the moment the universe joins, the index stops meaning anything to - * the caller (`validatePresetComparands` already runs on `page` writes and emits - * paths into this collection). Derived, the two move together by construction - * and the next widening is a one-key edit again. + * mapped type. The pairing is the rule rather than a coincidence, and [#17063] + * proves it in the other direction: retiring `validateViewPageRefs` with the + * `type: 'page'` view mount took the live page universe back out of + * `RuntimeStackContext`, and `pages` left this derived set with it, in the same + * one-key edit. That is the correct answer and not a regression — a `page` + * write's snapshot again holds exactly ONE page, its own, so `pages[0]` IS this + * write and name-keying it would say nothing the index does not + * (`validatePresetComparands` runs on `page` writes and emits paths into this + * collection; positional resolves for it again). * * ## Measured against the list it replaces (#13390) * - * Same four members in the same order — `objects`, `permissions`, `books`, - * `pages`. `datasets` falls out on its own, for exactly the reason the old - * comment had to state by hand: it is context-only, no write type maps into it. - * So **no member needed a hand-written exception** and none is kept. If a future - * member ever does need one, state it here WITH its reason — quietly - * re-introducing a literal is the thing this constant now exists to prevent. + * The members and their order are derived, never transcribed — today `objects`, + * `permissions`, `books`. `datasets` falls out on its own, for exactly the + * reason the old comment had to state by hand: it is context-only, no write type + * maps into it. So **no member needed a hand-written exception** and none is + * kept. If a future member ever does need one, state it here WITH its reason — + * quietly re-introducing a literal is the thing this constant now exists to + * prevent. */ const NAME_KEYED_STACK_KEYS: readonly string[] = deriveNameKeyedStackKeys( CONTEXT_STACK_KEYS, diff --git a/packages/lint/src/runtime-gate.view-page-refs.test.ts b/packages/lint/src/runtime-gate.view-page-refs.test.ts deleted file mode 100644 index 24b30b973e..0000000000 --- a/packages/lint/src/runtime-gate.view-page-refs.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#13216] `validateViewPageRefs` at the runtime publish gate, and the - * `RuntimeStackContext.pages` widening it rides on — measured BOTH ways. - * - * ## Why the crossing is the point rather than a bonus - * - * The mount this rule guards is authored on exactly one door: an agent - * publishes a page through the metadata API, then writes a `type: 'page'` view - * that mounts it. Both writes are `PUT /api/v1/meta/view` / `.../meta/page` — - * no CLI is involved anywhere on that path (#13100's measured trail), so a - * build-time-only rule would never speak to the author who needs it. - * - * ## Why the widening is load-bearing, stated as a measurement - * - * `ReferenceIntegrityRule.runtimeTypes` exists to keep one channel closed: a - * member that resolves against a collection the per-write snapshot does not - * carry does not go quiet, it reports EVERY reference into that collection as - * dead. `pages` was not a snapshot collection before this card. The two tests - * under "the widening" are that channel, opened and closed: identical write, - * identical rule, and the only difference is whether the host handed the gate - * its live pages. - * - * ## What the gate does with the finding - * - * `warning` — so it lands in `advisories`, never in `errors`, and never 422s a - * write. That is deliberate and matches the nav twin: without a curated - * cross-package page registry, "unresolved here" cannot be told apart from - * "provided by a package this tenant cannot see from here", and inventing a - * refusal out of that ambiguity would break legitimate publishes. The - * `errors` assertions below pin it. - */ - -import { describe, expect, it } from 'vitest'; - -import { runRuntimeAuthoringRules } from './runtime-gate.js'; -import { VIEW_PAGE_UNRESOLVED } from './validate-view-page-refs.js'; - -/** The live object universe every `view` write is resolved against. */ -const objects = [ - { name: 'crm_lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }, -]; - -/** - * A flattened standalone list overlay mounting a page, exactly as - * `saveMetaItem` stores it: a raw ListView config at the TOP level with - * `object` + `viewKind` (#7741) and the identity the write path stamps. - */ -const pageMountOverlay = (pageName: string) => ({ - name: 'crm_lead.dashboard', - object: 'crm_lead', - viewKind: 'list', - type: 'page', - pageName, - columns: [], -}); - -const gate = (item: unknown, context: Record) => - runRuntimeAuthoringRules({ type: 'view', item, context }); - -const pageFindings = (r: { errors: { rule: string }[]; advisories: { rule: string }[] }) => ({ - errors: r.errors.filter((f) => f.rule === VIEW_PAGE_UNRESOLVED), - advisories: r.advisories.filter((f) => f.rule === VIEW_PAGE_UNRESOLVED), -}); - -describe('the `pages` widening (RuntimeStackContext.pages) — measured both ways', () => { - it('WITHOUT live pages, a legitimate mount reads as dangling — the channel this closes', () => { - const { advisories } = pageFindings(gate(pageMountOverlay('sales_dashboard'), { objects })); - // Not an assertion about desired behaviour: this is the false-positive - // channel, reproduced. It is why the member could not simply declare - // `runtimeTypes: ['view']` and stop. - expect(advisories).toHaveLength(1); - }); - - it('WITH live pages carried, the same write is clean', () => { - const { errors, advisories } = pageFindings(gate( - pageMountOverlay('sales_dashboard'), - { objects, pages: [{ name: 'sales_dashboard', label: 'Sales' }] }, - )); - expect(advisories).toEqual([]); - expect(errors).toEqual([]); - }); -}); - -describe('a page mount at the runtime publish gate', () => { - it('ADVISES on a mount that resolves to no page — and never blocks the write', () => { - const { errors, advisories } = pageFindings(gate( - pageMountOverlay('typo_dashboard'), - { objects, pages: [{ name: 'sales_dashboard' }] }, - )); - expect(advisories).toHaveLength(1); - expect(advisories[0].rule).toBe(VIEW_PAGE_UNRESOLVED); - // Advisory, not gating: the honest ceiling for a page reference (see the - // module docblock). A refusal here would 422 a legitimate cross-package - // mount. - expect(errors).toEqual([]); - }); - - it('runs the suite for a `view` write at all', () => { - const { rulesRun } = runRuntimeAuthoringRules({ - type: 'view', - item: pageMountOverlay('sales_dashboard'), - context: { objects, pages: [{ name: 'sales_dashboard' }] }, - }); - expect(rulesRun).toContain('validateReferenceIntegrity'); - }); - - // The differential (#4463 D4): a stored page's own condition is not this - // write's to answer for, and a stored VIEW's dangling mount must not block an - // unrelated write either. Carrying `pages` in both passes is what cancels it. - it('does not attribute a pre-existing stored page to this write', () => { - const { errors, advisories } = pageFindings(gate( - pageMountOverlay('sales_dashboard'), - { - objects, - pages: [{ name: 'sales_dashboard' }, { name: 'unrelated_page' }], - }, - )); - expect(advisories).toEqual([]); - expect(errors).toEqual([]); - }); -}); diff --git a/packages/lint/src/runtime-gate.view-writes.test.ts b/packages/lint/src/runtime-gate.view-writes.test.ts index dbd69d3516..54bd2ca570 100644 --- a/packages/lint/src/runtime-gate.view-writes.test.ts +++ b/packages/lint/src/runtime-gate.view-writes.test.ts @@ -222,14 +222,13 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { const crossed = REFERENCE_INTEGRITY_RULES .filter((r) => (r.runtimeTypes ?? ['flow']).includes('view')) .map((r) => r.name); - // [#13216] `validateViewPageRefs` is the third crossing, and the first that - // is not a field-existence question: a `type: 'page'` view's `pageName`, - // resolved against `stack.pages` — a collection the per-write snapshot - // gained in the same change (`RuntimeStackContext.pages`), which is the - // precondition every crossing owes. The list is written out, not derived, - // precisely so a fourth crossing has to be argued here; this one's - // false-positive measurement is `runtime-gate.view-page-refs.test.ts`, - // which reproduces the phantom findings the collection removes. + // [#13216 / #17063] `validateViewPageRefs` was the third crossing, and the + // only one that was not a field-existence question: a `type: 'page'` view's + // `pageName`, resolved against `stack.pages` — a collection the per-write + // snapshot gained in the same change. It left this list with the view mount + // it resolved, and `RuntimeStackContext.pages` left with it, so no crossing + // reaches for a collection the snapshot does not carry. The list is written + // out, not derived, precisely so a change either way has to be argued here. // [#14107] The fourth crossing, argued here as this list demands. It is the // same KIND of crossing as the first two — a list view's field references, // resolved against `stack.objects`, the one collection every per-write @@ -243,7 +242,6 @@ describe('a flattened list overlay at the runtime publish gate (#9313)', () => { 'validateSearchableFields', 'validateSortableFields', 'validateListViewFieldRefs', - 'validateViewPageRefs', ]); // And every member still judges flow snapshots — the #4463 P1 surface is // not narrowed by the member axis existing. diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index 70560db7dc..2ae97b7b6f 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -190,8 +190,9 @@ * owned by `validateActionNameRefs`. * - **`conditionalFormatting[].condition`** — a CEL predicate, owned by the * expression rules. - * - **`pageName` / `tabs[].view` / `addRecord.formView`** — page and view - * names, owned by `validateViewPageRefs` and `lintViewRefs`. + * - **`tabs[].view` / `addRecord.formView`** — view names, owned by + * `lintViewRefs`. (`pageName` was here too until #17063 retired the + * `type: 'page'` view mount; a list view carries no page reference now.) * - **The `data.object` binding itself** — `validateObjectReferences` owns * object-name reference sites, with the curated cross-package severity * ladder a local "not in this stack ⇒ error" would not have. When the bound diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts index cb4dac3cc3..0156ee8305 100644 --- a/packages/lint/src/validate-predicate-path-refs.test.ts +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -550,7 +550,13 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // Exactly two entries were added — `field :: valueDomain` and // `object :: valueDomain`, both `data.type in ['text']` — and NONE was // removed. Earlier measurements stay what they were: history, not the census. - expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(53); + // It is 52 today, and this one is a REMOVAL: #17063 retired the + // `type: 'page'` list-view mount under ADR-0049 enforce-or-remove, and + // `view.form.ts`'s `page` section — the single `pageName` input gated + // `data.type == 'page'` — went with the key it configured. A form input for + // an unwritable key is the false-compliant UI half of a retirement, so the + // census falls by exactly one. + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(52); const findings = validatePredicatePathRefs(corrupted); expect(findings).toHaveLength(predicates); @@ -624,11 +630,13 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { // and `in`-list literals are deliberately not this rule's (see the anchor // note above) — exactly the #11566 (PR #11989) respell of the sibling // `maxLength` row, which took the measurement from 47 to 44. Earlier - // measurements stay what they were — history, not the census. It is 42 - // today: #13216's `page` section in `view.form.ts` is gated by - // `data.type == 'page'`, one more `==` literal comparison of exactly the - // shape the seven sibling surface blocks already carry. - expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(42); + // measurements stay what they were — history, not the census. It was 42 + // when #13216's `page` section in `view.form.ts` carried a + // `data.type == 'page'` gate; it is 41 today because #17063 retired that + // section with the `type: 'page'` mount it configured (ADR-0049 + // enforce-or-remove), taking that one `==` literal comparison with it. The + // seven sibling surface blocks that share its shape are untouched. + expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(41); const rhsFindings = validatePredicatePathRefs(corrupted) .filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED); diff --git a/packages/lint/src/validate-view-page-refs.test.ts b/packages/lint/src/validate-view-page-refs.test.ts deleted file mode 100644 index d52057c0dd..0000000000 --- a/packages/lint/src/validate-view-page-refs.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Tests for the `type: 'page'` view → page reference rule (#13216). - * - * Three of these carry the rule's reason for existing: - * - * 1. The **empty-collection** case — the same hole its nav twin was written - * for. `defineStack`'s cross-reference check is gated on - * `pageNames.size > 0`, so a stack that declares no `pages` has its - * view→page validation switched off, and that is exactly the state a stack - * is in when the target was never written. "Optimise" this rule by skipping - * page-less stacks and this test goes red. - * 2. The **flattened-overlay rung**. `runtimeTypes: ['flow','view']` on the - * suite member is necessary and NOT sufficient (#9313's measured lesson): - * the shape `PUT /api/v1/meta/view` carries is a list view at the TOP level - * of a `views[]` entry, not under `list` / `listViews`. A walk that reads - * only the container rungs would make the crossing a silent no-op that - * reads as coverage. - * 3. The **object rung** (`objects[].listViews.*`), which is where an object's - * built-in views are authored and where a page tab is most likely written. - */ - -import { describe, expect, it } from 'vitest'; - -import { validateViewPageRefs, VIEW_PAGE_UNRESOLVED } from './validate-view-page-refs.js'; - -const mount = (pageName: string) => ({ type: 'page', pageName, columns: [] }); - -describe('validateViewPageRefs — the gap defineStack leaves', () => { - it('flags a mount when the stack declares NO pages (the size>0 hole)', () => { - const findings = validateViewPageRefs({ - views: [{ name: 'crm_lead', list: mount('missing_page') }], - }); - expect(findings).toHaveLength(1); - const [f] = findings; - expect(f.rule).toBe(VIEW_PAGE_UNRESOLVED); - expect(f.severity).toBe('warning'); - expect(f.path).toBe('views[0].list.pageName'); - expect(f.where).toBe('view "crm_lead" › list'); - // The message must say WHY nothing else caught it, or the author has no way - // to know this rule is the only thing speaking. - expect(f.message).toContain('NO pages at all'); - expect(f.message).toContain('size > 0'); - }); - - it('flags a mount when pages exist but the name is wrong', () => { - const findings = validateViewPageRefs({ - views: [{ name: 'crm_lead', list: mount('typo') }], - pages: [{ name: 'sales_dashboard' }], - }); - expect(findings).toHaveLength(1); - expect(findings[0].message).not.toContain('NO pages at all'); - expect(findings[0].message).toContain("mounts page 'typo'"); - }); - - it('is silent when the mount resolves', () => { - expect(validateViewPageRefs({ - views: [{ name: 'crm_lead', list: mount('sales_dashboard') }], - pages: [{ name: 'sales_dashboard' }], - })).toEqual([]); - }); - - it('is silent on every view type that is not a page mount', () => { - expect(validateViewPageRefs({ - views: [{ name: 'crm_lead', list: { type: 'grid', columns: ['name'] } }], - pages: [], - })).toEqual([]); - }); - - it('accepts a name-keyed `pages` map as well as an array', () => { - expect(validateViewPageRefs({ - views: [{ name: 'crm_lead', list: mount('sales_dashboard') }], - pages: { sales_dashboard: { label: 'Sales' } }, - })).toEqual([]); - }); -}); - -describe('validateViewPageRefs — every rung a page mount can be authored on', () => { - // The runtime write door's own shape: a list view at the TOP level of a - // `views[]` entry (`viewKind: 'list'`, no nested `config`) — the shape the - // runtime publish gate snapshots as `views: [item]`. Without this rung the - // suite member's `view` crossing reports nothing at all. - it('walks the FLATTENED list overlay (PUT /api/v1/meta/view)', () => { - const findings = validateViewPageRefs({ - views: [{ - name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', ...mount('missing_page'), - }], - pages: [{ name: 'sales_dashboard' }], - }); - expect(findings).toHaveLength(1); - expect(findings[0].path).toBe('views[0].pageName'); - expect(findings[0].where).toContain('flattened list overlay'); - }); - - it('walks the standalone ViewItem RECORD (config one level down)', () => { - const findings = validateViewPageRefs({ - views: [{ - name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', config: mount('missing_page'), - }], - pages: [{ name: 'sales_dashboard' }], - }); - expect(findings).toHaveLength(1); - expect(findings[0].path).toBe('views[0].config.pageName'); - expect(findings[0].where).toContain('ViewItem record'); - }); - - it('walks `objects[].listViews.` — the object\'s built-in views', () => { - const findings = validateViewPageRefs({ - objects: [{ name: 'crm_lead', listViews: { dash: mount('missing_page') } }], - pages: [{ name: 'sales_dashboard' }], - }); - expect(findings).toHaveLength(1); - expect(findings[0].path).toBe('objects[0].listViews.dash.pageName'); - expect(findings[0].where).toBe('object "crm_lead" › listViews.dash'); - }); - - it('walks `views[].listViews.` — a defineView aggregate\'s named views', () => { - const findings = validateViewPageRefs({ - views: [{ name: 'crm_lead', listViews: { dash: mount('missing_page') } }], - pages: [{ name: 'sales_dashboard' }], - }); - expect(findings).toHaveLength(1); - expect(findings[0].path).toBe('views[0].listViews.dash.pageName'); - }); - - // A record body carries `config`; the overlay body does not. The two rungs - // must not both fire on one entry — that would double-report one mount. - it('reports a ViewItem record exactly once, not once per rung', () => { - const findings = validateViewPageRefs({ - views: [{ name: 'v', object: 'crm_lead', viewKind: 'list', config: mount('missing_page') }], - pages: [{ name: 'other' }], - }); - expect(findings).toHaveLength(1); - }); -}); - -describe('validateViewPageRefs — deliberate non-findings', () => { - it('skips an interpolated target (resolved at render time, ADR-0072 D1)', () => { - expect(validateViewPageRefs({ - views: [{ name: 'v', list: { type: 'page', pageName: '${page}', columns: [] } }], - pages: [{ name: 'sales_dashboard' }], - })).toEqual([]); - }); - - it('is total on junk input rather than throwing', () => { - expect(validateViewPageRefs(undefined)).toEqual([]); - expect(validateViewPageRefs({ views: 'nope', objects: 7, pages: null })).toEqual([]); - expect(validateViewPageRefs({ views: [null, 3], objects: [null] })).toEqual([]); - }); -}); diff --git a/packages/lint/src/validate-view-page-refs.ts b/packages/lint/src/validate-view-page-refs.ts deleted file mode 100644 index 7b9849d3fe..0000000000 --- a/packages/lint/src/validate-view-page-refs.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#13216 — reference resolvability] A `type: 'page'` list view's `pageName` - * must name a page the stack actually declares. - * - * ## What the schema already settles, and what it cannot - * - * `ListViewSchema`'s `checkListViewPageMount` refuses a `page` view with no - * `pageName`, refuses `pageName` on any other view type, and refuses a - * `pageName` outside `SnakeCaseIdentifierSchema`'s grammar — so by the time a - * body reaches here the key is present, well-formed, and on the one view type - * that reads it. What a per-body parse cannot ask is whether the page EXISTS, - * because the answer lives in a sibling collection the body never sees. That is - * this rule's whole question, and it is the same question - * `validate-nav-target-refs` asks about the identical reference one surface - * over — `{ type: 'page', pageName }` on an app navigation item. - * - * ## Severity: warning, for its twin's reason - * - * `validate-nav-target-refs` explains why the honest ceiling for a page - * reference is advisory: unlike objects, pages have no curated - * cross-package registry, so "unresolved here" cannot be told apart from - * "provided by a package this stack cannot see". Nothing about mounting the - * same page on a view changes that, so the verdict matches its twin rather - * than inventing a second severity for one reference kind. `defineStack`'s - * `validateCrossReferences` still hard-fails first whenever the stack DOES - * declare pages — this rule is what speaks when that check has switched itself - * off (`pageNames.size > 0`), which is exactly the state a stack is in when - * the target was never written. - * - * ## Why it runs at the runtime publish gate too (`runtimeTypes: view`) - * - * The mount this rule guards is reachable in exactly the way #13100 measured: - * an agent publishes a page through the metadata API, then writes a view that - * mounts it. Both writes go through `PUT /api/v1/meta/view` — no CLI is - * involved anywhere on that path, so a build-time-only rule would never speak - * to the author who needs it. Crossing the member onto `view` snapshots is - * therefore the point, not a bonus. - * - * It is safe to cross ONLY because the per-write snapshot now carries `pages` - * (`RuntimeStackContext.pages`, added with this rule). Without that collection - * the member would not go quiet — it would report EVERY page mount as dead, - * which is the missing-collection false-positive channel - * `ReferenceIntegrityRule.runtimeTypes` exists to keep closed, and the reason - * `validateNavTargetRefs` is NOT crossed: nothing carries a snapshot's `apps`. - * - * ## The rungs, and why the list matches the sort rule's - * - * A `pageName` can be authored on every shape that carries a list view, and the - * #9313 lesson is that declaring `runtimeTypes` is necessary and NOT sufficient - * — the WALK has to reach the flattened top-level shape the write door actually - * carries, or the crossing is a silent no-op that reads as coverage. So the - * rungs are `validate-sortable-fields`' rungs, one key over: - * - * - `objects[].listViews.` — built-in named list views; - * - `views[]` itself on a FLATTENED LIST OVERLAY (`viewKind: 'list'`, no - * nested `config`) — the shape the runtime gate snapshots as `views: [item]`; - * - `views[].config` on a standalone ViewItem RECORD (`viewKind: 'list'` with - * a record-shaped `config`) — the shape a Studio-saved view round-trips as; - * - `views[].list` — a `defineView` aggregate's default list; - * - `views[].listViews.` — its named list views. - * - * ## Not covered, deliberately - * - * An interpolated target (`${…}`) resolves at render time and is skipped — the - * conservative exemption `validate-nav-target-refs` and - * `validate-object-references` both use (ADR-0072 D1). It cannot currently - * arise (the schema's snake_case grammar refuses `$` and `{`), and is kept so - * this rule does not become the thing that has to change if the grammar ever - * widens. - * - * The view's `type` is NOT re-checked here. A body reaching a CLI command has - * been parsed; a body reaching `os lint` may not have been, and in that state - * reading `pageName` wherever it is written is the more useful answer — a - * `pageName` on a `grid` view is a schema refusal, not this rule's business, - * and reporting the dead reference underneath it as well helps rather than - * misleads. - */ - -import type { ReferenceIntegrityFinding } from './reference-integrity-suite.js'; -import { recordsOf } from './object-graph.js'; - -export type ViewPageRefSeverity = 'error' | 'warning'; -export type ViewPageRefFinding = ReferenceIntegrityFinding; - -/** Emitted when a `type: 'page'` view mounts a page the stack cannot resolve. */ -export const VIEW_PAGE_UNRESOLVED = 'view-page-unresolved'; - -type AnyRec = Record; - -const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); - -function strName(v: unknown): string | undefined { - return typeof v === 'string' && v.length > 0 ? v : undefined; -} - -/** See the module docblock — an interpolated target resolves at render time. */ -const isInterpolated = (s: string): boolean => s.includes('${') || s.includes('{'); - -function declaredPageNames(stack: AnyRec): Set { - const out = new Set(); - for (const page of recordsOf(stack.pages)) { - const n = strName(page.name); - if (n) out.add(n); - } - return out; -} - -export function validateViewPageRefs(stack: unknown): ViewPageRefFinding[] { - const findings: ViewPageRefFinding[] = []; - if (!isRec(stack)) return findings; - - const pages = declaredPageNames(stack); - - const check = (listView: unknown, where: string, path: string): void => { - if (!isRec(listView)) return; - const target = strName(listView.pageName); - if (!target || isInterpolated(target)) return; - if (pages.has(target)) return; - - const emptyCollection = pages.size === 0; - findings.push({ - severity: 'warning', - rule: VIEW_PAGE_UNRESOLVED, - where, - path: `${path}.pageName`, - message: - `This view mounts page '${target}', which this stack does not declare in \`pages\`. ` - + (emptyCollection - ? 'The stack declares NO pages at all, so `defineStack`\'s own cross-reference check ' - + 'skipped this entry entirely (it is gated on `pageNames.size > 0`) — nothing else ' - + 'will report it. ' - : '') - + 'The view appears in the object\'s view switcher and renders nothing when opened: a ' - + '`page` view has no rows of its own to fall back to. If another package provides this ' - + 'page, this is expected and advisory only.', - hint: - `Declare the page in \`pages\`, correct \`pageName\`, or change the view's \`type\` away ` - + 'from `page` if the mount is no longer wanted.', - }); - }; - - // ── The object's built-in named list views ── - for (const [oi, obj] of recordsOf(stack.objects).entries()) { - const objName = strName(obj.name); - const label = objName ? `object "${objName}"` : `objects[${oi}]`; - if (!isRec(obj.listViews)) continue; - for (const [key, lv] of Object.entries(obj.listViews)) { - check(lv, `${label} › listViews.${key}`, `objects[${oi}].listViews.${key}`); - } - } - - // ── `views[]`: the flattened overlay / ViewItem record / container rungs ── - const views = Array.isArray(stack.views) ? (stack.views as unknown[]) : []; - for (const [vi, view] of views.entries()) { - if (!isRec(view)) continue; - const viewLabel = strName(view.name) ?? strName(view.objectName) ?? `#${vi}`; - - if (view.viewKind === 'list' && !isRec(view.config)) { - check(view, `view "${viewLabel}" (flattened list overlay)`, `views[${vi}]`); - } - if (view.viewKind === 'list' && isRec(view.config)) { - check(view.config, `view "${viewLabel}" (ViewItem record)`, `views[${vi}].config`); - } - if (isRec(view.list)) { - check(view.list, `view "${viewLabel}" › list`, `views[${vi}].list`); - } - if (isRec(view.listViews)) { - for (const [key, lv] of Object.entries(view.listViews)) { - check(lv, `view "${viewLabel}" › listViews.${key}`, `views[${vi}].listViews.${key}`); - } - } - } - - return findings; -} diff --git a/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts index 9955c9e064..a7313f5c2e 100644 --- a/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-gate-stored-universe.test.ts @@ -61,7 +61,6 @@ import { assertEngineFindOnePredicate, assertEngineUpdateDispatch, } from '@objectstack/metadata-core'; -import { VIEW_PAGE_UNRESOLVED } from '@objectstack/lint'; import { ObjectStackProtocolImplementation } from './protocol.js'; const WIDGET_DATASET_UNKNOWN = 'widget-dataset-unknown'; @@ -109,16 +108,6 @@ const threeWidgetBoard = (dataset: string) => ({ ], }); -/** A standalone list overlay mounting a page, as `saveMetaItem` stores one. */ -const pageMountView = (pageName: string) => ({ - name: 'orders.dashboard', - object: 'orders', - viewKind: 'list', - type: 'page', - pageName, - columns: [], -}); - interface Row { id: string; type: string; @@ -333,32 +322,14 @@ describe('#15950 — the authoring gate resolves against runtime-authored metada expect(result.success).toBe(true); }); - it('folds the store into EVERY context collection, not just `datasets`', async () => { - // The gather is one helper serving five collections, so the repair is - // one helper too. `pages` is the arm triage asked for a reading on: - // same shape, lower severity — `validateViewPageRefs` reports at - // `warning`, so the phantom rode in `advisories` instead of 422-ing the - // write. Measured both ways here. - const { protocol } = makeHarness(); - - const unknownMount = await protocol.saveMetaItem({ - type: 'view', name: 'orders.dashboard', item: pageMountView('never_authored_page'), - }); - expect( - (unknownMount.advisories ?? []).map((a: any) => a.rule), - 'the control: an unresolvable page mount is still reported', - ).toContain(VIEW_PAGE_UNRESOLVED); - - await protocol.saveMetaItem({ - type: 'page', name: 'sales_dashboard', item: { name: 'sales_dashboard', label: 'Sales' }, - }); - const authoredMount = await protocol.saveMetaItem({ - type: 'view', name: 'orders.dashboard', item: pageMountView('sales_dashboard'), - }); - expect(authoredMount.success).toBe(true); - expect( - (authoredMount.advisories ?? []).map((a: any) => a.rule), - 'a page saved through `PUT /meta/page` is a live page', - ).not.toContain(VIEW_PAGE_UNRESOLVED); - }); + // [#17063] A second arm of this test measured the SAME repair on `pages`: + // `validateViewPageRefs` reported an unresolvable `type: 'page'` list-view + // mount at `warning`, so the phantom rode in `advisories` rather than + // 422-ing the write, and the fold was measured both ways. That rule and the + // mount it resolved were retired under ADR-0049 enforce-or-remove + // (maintainer ruling 2026-09-09 「撤」), the live page universe left + // `RuntimeStackContext` with them, and the arm left with the universe. The + // `datasets` arm above is unchanged and still measures the helper — the + // fold is one helper serving the surviving collections, which is what made + // this a single repair in the first place. }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d7ca6399e..1007eacfda 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4856,7 +4856,7 @@ export class ObjectStackProtocolImplementation implements // `sys_metadata` read and they do not depend on one another, so the // store leg costs one round trip of latency for the whole context // instead of five. - const [objects, permissions, books, datasets, pages] = await Promise.all([ + const [objects, permissions, books, datasets] = await Promise.all([ listCollection('object', 'objects'), listCollection('permission', 'permissions'), listCollection('book', 'books'), @@ -4864,11 +4864,12 @@ export class ObjectStackProtocolImplementation implements // dashboard publish — without it every legitimate board reads as // dangling (see RuntimeStackContext.datasets). listCollection('dataset', 'datasets'), - // [#13216] The resolution universe validateViewPageRefs needs for a - // `type: 'page'` view publish — without it every legitimate page mount - // reads as dangling (see RuntimeStackContext.pages). Gathered on the - // same terms as the four above: per write, on an `active` publish only. - listCollection('page', 'pages'), + // [#17063] A fifth read, `listCollection('page', 'pages')`, stood + // here for `validateViewPageRefs`. Both it and the `type: 'page'` + // view mount it resolved were retired under ADR-0049 + // enforce-or-remove, so no runtime-crossed rule reads `stack.pages` + // and this publish no longer pays a `sys_metadata` round trip for a + // collection nothing would consult. ]); // [#9612] The closure this write is judged against. Resolved from the @@ -4885,8 +4886,7 @@ export class ObjectStackProtocolImplementation implements permissions, books, datasets, - pages, - // [#10377] The batch's own pending drafts join the five + // [#10377] The batch's own pending drafts join the // collections above. Absent on every non-batch door. ...(evt.pending !== undefined ? { pending: evt.pending } : {}), ...(packageScope !== undefined ? { packageScope } : {}), @@ -17375,13 +17375,15 @@ export class ObjectStackProtocolImplementation implements // the third place the collection set is written down, and the only one // that could fall behind SILENTLY: a key added to `RuntimeStackContext` // and routed by `CLOSURE_CONTEXT_KEY_BY_TYPE` would simply never be - // accumulated here, so a package publishing a page beside the view that - // mounts it would keep being refused for the sibling in its own batch — - // the `shyx_customer_ds` shape #10377 was filed for. `-?` makes every - // key REQUIRED, so the next widening is a compile error at this line - // instead. + // accumulated here, so a package publishing a dataset beside the + // dashboard that binds it would keep being refused for the sibling in + // its own batch — the `shyx_customer_ds` shape #10377 was filed for. + // `-?` makes every key REQUIRED, so the next widening is a compile error + // at this line instead — and [#17063]'s NARROWING was one too, which is + // how `pages` left this literal in the same edit it left + // `RuntimeStackContext`. const pending: { [K in keyof RuntimePendingDeclarations]-?: unknown[] } = { - objects: [], permissions: [], books: [], datasets: [], pages: [], + objects: [], permissions: [], books: [], datasets: [], }; let any = false; for (const d of drafts) { diff --git a/packages/metadata-protocol/src/runtime-authoring-gate.ts b/packages/metadata-protocol/src/runtime-authoring-gate.ts index e1af707e46..a80e778cae 100644 --- a/packages/metadata-protocol/src/runtime-authoring-gate.ts +++ b/packages/metadata-protocol/src/runtime-authoring-gate.ts @@ -383,22 +383,22 @@ export type RuntimePendingDeclarations = RuntimeStackContext; * * Every metadata type NOT listed here contributes nothing to the closure, and * that is the correct answer rather than a gap: a collection is carried - * because some rule RESOLVES REFERENCES INTO IT, and only these five are read - * that way (`RuntimeStackContext`'s own docblock records the measurement). - * - * [#13216] `page` is the fifth, and it arrives with the closure case already - * measured rather than as an afterthought: a package that ships a custom page - * together with the object view that mounts it publishes BOTH in one batch, so - * without this row `validateViewPageRefs` would report the sibling page as - * unresolved for exactly the batch that carries it — the `shyx_customer_ds` - * shape one collection over. + * because some rule RESOLVES REFERENCES INTO IT, and only these are read that + * way (`RuntimeStackContext`'s own docblock records the measurement). + * + * [#13216 / #17063] `page` WAS the fifth row, added for `validateViewPageRefs` + * so that a package shipping a custom page together with the object view that + * mounted it did not report its own sibling as unresolved. That rule and the + * `type: 'page'` view mount it resolved were retired under ADR-0049 + * enforce-or-remove, `RuntimeStackContext.pages` went with them, and this row + * went with that — the drift guard below is what made the third edit + * unforgettable rather than remembered. */ export const CLOSURE_CONTEXT_KEY_BY_TYPE = { object: 'objects', permission: 'permissions', book: 'books', dataset: 'datasets', - page: 'pages', } as const satisfies Readonly>; /** @@ -441,7 +441,8 @@ type NoUnroutedContextCollection = Unrouted; * * The set is correct as it stands. #13390's ruling is about what "correct * today" costs: adding the `pages` collection had to touch FIVE spellings of - * this one set and only ONE announced itself, and the unguarded spelling + * this one set and only ONE announced itself (#17063 removed it again, and the + * same five spellings had to move back), and the unguarded spelling * produced correct-LOOKING findings whose `path` the caller could not resolve, * with no test and no gate going red. Four of the five can no longer be * forgotten. This was the fifth. @@ -603,14 +604,6 @@ export function evaluateRuntimeAuthoringGate(args: { * so the thread-through is load-bearing, not optional. */ datasets?: readonly unknown[]; - /** - * [#13216] Live page declarations — the resolution universe - * `validateViewPageRefs` resolves a `type: 'page'` list view's `pageName` - * against. Without it every legitimate page mount published through - * `PUT /api/v1/meta/view` reads as dangling, so the thread-through is - * load-bearing on exactly the door the mount is authored at. - */ - pages?: readonly unknown[]; /** * [#10377] The declarations this write's own BATCH is publishing alongside * it — folded into the five collections above by @@ -686,7 +679,6 @@ export function evaluateRuntimeAuthoringGate(args: { permissions: mergePendingDeclarations(args.permissions ?? [], args.pending?.permissions), books: mergePendingDeclarations(args.books ?? [], args.pending?.books), datasets: mergePendingDeclarations(args.datasets ?? [], args.pending?.datasets), - pages: mergePendingDeclarations(args.pages ?? [], args.pending?.pages), }, ...(args.sduiManifest !== undefined ? { sduiManifest: args.sduiManifest } : {}), }); diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 750cc34cd6..a160acecf0 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -432,7 +432,6 @@ "chartAggregateValueKey (function)", "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", - "checkListViewPageMount (function)", "checkPageSourceCompleteness (function)", "columnSummaryAlias (function)", "compileListViewGroupQuery (function)", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 1b29088887..375063a67f 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -648,7 +648,7 @@ "ui/ListView:map", "ui/ListView:name", "ui/ListView:navigation", - "ui/ListView:pageName", + "ui/ListView:pageName [RETIRED]", "ui/ListView:pagination", "ui/ListView:performance [RETIRED]", "ui/ListView:resizable", @@ -812,7 +812,7 @@ "ui/ObjectListView:map", "ui/ObjectListView:name", "ui/ObjectListView:navigation", - "ui/ObjectListView:pageName", + "ui/ObjectListView:pageName [RETIRED]", "ui/ObjectListView:pagination", "ui/ObjectListView:performance [RETIRED]", "ui/ObjectListView:resizable", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index e0f1611736..11d27694e9 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -418,7 +418,6 @@ "chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)", "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", - "checkListViewPageMount": "src/ui/view.zod.ts#checkListViewPageMount (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", "columnSummaryAlias": "src/ui/view-grouping-query.ts#columnSummaryAlias (function)", "compileListViewGroupQuery": "src/ui/view-grouping-query.ts#compileListViewGroupQuery (function)", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 5575daa719..06956d2da1 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -39,7 +39,7 @@ for both corollaries. | `skill` | 16 | 0 | 0 | 1 | 0 | 17 | | `dataset` | 27 | 0 | 0 | 0 | 0 | 27 | | `page` | 23 | 0 | 0 | 0 | 1 | 24 | -| `view` | 78 | 0 | 0 | 10 | 0 | 88 | +| `view` | 77 | 0 | 0 | 11 | 0 | 88 | | `report` | 21 | 0 | 0 | 0 | 0 | 21 | | `dashboard` | 34 | 0 | 0 | 8 | 0 | 42 | | `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | @@ -63,4 +63,4 @@ for both corollaries. | `batch_endpoints` | 5 | 0 | 0 | 2 | 0 | 7 | | `route_generation` | 0 | 0 | 0 | 4 | 0 | 4 | | `realtime_subscription` | 0 | 0 | 0 | 6 | 0 | 6 | -| **total** | **851** | **5** | **1** | **92** | **10** | **959** | +| **total** | **850** | **5** | **1** | **93** | **10** | **959** | diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index e30c9edff3..0fc10f95ac 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -34,7 +34,7 @@ }, "type": { "status": "live", - "note": "objectui: ListView.tsx routes grid/kanban/calendar/gantt/gallery/timeline/chart/tree renderers (audit L16). [#13216] The enum also carries `page`, which routes nowhere in ListView by design — it delegates to the existing page renderer via the sibling `pageName` key (maintainer ruling 2026-08-29); the mount itself is objectui's half." + "note": "objectui: ListView.tsx routes grid/kanban/calendar/gantt/gallery/timeline/chart/tree renderers (audit L16). [#17063] The enum carried a ninth member, `page`, which routed nowhere in ListView — it was to delegate to the existing page renderer via the sibling `pageName` key (#13216, maintainer ruling 2026-08-29) and objectui's half was never built. Retired 2026-09-10 under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-09 「撤」): the value is refused by name on the enum's own error map, which is where an enum-VALUE narrowing's prescription has to live. Every member listed above is still routed by that switch." }, "data": { "status": "live", @@ -119,10 +119,9 @@ "note": "objectui: @object-ui/plugin-tree ObjectTree.tsx (spec CHANGELOG: 'Renderer ships in objectui @object-ui/plugin-tree')." }, "pageName": { - "status": "live", - "verifiedAt": "2026-08-30", - "evidence": "packages/spec/src/ui/view.zod.ts#checkListViewPageMount (refuses a `type: 'page'` view that declares no `pageName`, refuses `pageName` on any other view type, and refuses a non-empty `columns` beside it — attached to all three list-view doors: ListViewSchema, ObjectListViewSchema, ListViewOverlayWireSchema); packages/spec/src/stack.zod.ts#validateCrossReferences (build-time refusal when the named page is not in `stack.pages`); packages/lint/src/validate-view-page-refs.ts#validateViewPageRefs (the same resolution on the CLI commands and, via `runtimeTypes: ['flow','view']`, on the runtime publish gate — `PUT /api/v1/meta/view`)", - "note": "[#13216] The page a `type: 'page'` view mounts. Enforcement is in-repo and cited above; the RENDER half is objectui's and is delegation rather than new code — the ruling of 2026-08-29 is explicit that a page view hands off to the existing page renderer (「渲染委托既有页面渲染器」), so the page keeps its own audience gate (`page.assignedProfiles`) and no new authorization surface is introduced. Necessary-not-sufficient in the ADR-0054 sense until the objectui mount lands: the refusals above are measured here, the pixels are not." + "status": "dead", + "verifiedAt": "2026-09-10", + "note": "REMOVED 2026-09-10 (#17063, ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision batch #107 item 1, verbatim 「撤」) — the enforcement the prior `live` row cited was real but circular: `checkListViewPageMount`, the `validateCrossReferences` branch and `validateViewPageRefs` all policed a mount NO RENDERER EVER PERFORMED. Only the spec half of #13216 landed (PR #13372); objectui's list-view switch shares its `default:` arm with `case 'grid'` and `isListViewVisualization('page')` is false, so a page view drew an empty grid and the named page was never reached. Necessary-not-sufficient in the ADR-0054 sense was the prior note's own verdict, and the mount half was never built (objectui#8429). Tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources by the protocol-18 view-page-mount-removed conversion, which also strips the `type: 'page'` value the key was bound to. The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent); to put a published page in front of users, author an app navigation item — `{ type: 'page', pageName }` on `PageNavItemSchema`, a different key on a different surface, and the page mount that has always rendered." }, "map": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index df7ab777f7..defa9c501f 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8968,6 +8968,109 @@ const tursoConfigTimeoutToTimeoutMs: MetadataConversion = { }, }; +/** + * The `type: 'page'` list-view mount leaves the spec (protocol 18, #17063 — + * maintainer ruling 2026-09-09, decision batch #107 item 1, verbatim 「撤」). + * + * `page` was added to the list-view `type` enum with a `pageName` binding so a + * view could render nothing of its own and delegate to an already-published + * page (#13216 direction 1). Only the spec half landed (PR #13372). No renderer + * ever routed the member: objectui's `ListView` switch shares its `default:` + * arm with `case 'grid'`, and `isListViewVisualization('page')` is false — so a + * `page` view has always drawn an empty grid where the page was supposed to be. + * ADR-0049 enforce-or-remove, on the principle that a published capability with + * zero consumers earns no exemption from its sunk cost. + * + * ## Why this STRIPS `type` rather than rewriting it to `'grid'` + * + * Deleting the key is not the platform choosing a view type: `type` carries + * `.default('grid')` on {@link ListViewSchema}, so a payload with no `type` + * parses to `grid` by the schema's own declared default — the same value, but + * declared in one place instead of guessed here. And it lands the row on + * exactly what it already rendered, so the conversion changes the document + * without changing a pixel. `stripKeys`-shaped deletion is idempotent by + * construction (a second replay finds nothing to remove). + * + * `pageName` is stripped unconditionally on a list payload, not only beside a + * `page` type: the key is retired on every list-view door, and a pre-#13372 row + * can carry it beside any type (the both-directions refusal that used to catch + * that is retired with the mount). + * + * `retiredFromLoadPath`: the enum refuses `'page'` by name and `pageName` is a + * `retiredKey()` tombstone, so a LIVE author is taught at parse rather than + * silently rewritten. The entry exists so stored 17.x rows replay clean through + * `applyConversionsToStoredItem`, and so `os migrate meta --from 17` lists the + * mechanical edits for author sources. + * + * ⚠️ Coverage boundary, stated rather than left to be discovered: this walks + * `stack.views[]` in all three persisted spellings ({@link mapViewPayloads}) — + * the same reach `view-export-options-pdf-removed` has, and the same reach the + * conversion walk offers. `objects[].listViews.*` is NOT reached by any + * conversion in this registry, so an object body carrying a page mount is + * refused at its own door rather than converted. Measured population for both: + * zero authored `type: 'page'` list views in this tree or any consuming app the + * seats can read (the 25 in-tree `type: 'page'` hits are app NAV items, a + * different key on `PageNavItemSchema` that stays). + */ +const viewPageMountRemoved: MetadataConversion = { + id: 'view-page-mount-removed', + toMajor: 18, + retiredFromLoadPath: true, + surface: "view.list / view.listViews.* — the list-view type 'page' and its pageName binding", + summary: + "list-view type 'page' and its `pageName` binding removed (#17063 — the delegating render half " + + 'was never built, so a page view fell through to the grid branch and drew an empty table; ' + + 'ADR-0049 enforce-or-remove)', + apply(stack, emit) { + const stripMount = (payload: Dict, path: string): Dict => { + const hasPageType = payload.type === 'page'; + const hasPageName = 'pageName' in payload; + if (!hasPageType && !hasPageName) return payload; + const next = { ...payload }; + if (hasPageType) { + // Deleted, not rewritten: `ListViewSchema.type` defaults to `'grid'`. + emit({ from: 'page', to: '(removed)', path: `${path}.type` }); + delete next.type; + } + if (hasPageName) { + emit({ from: 'pageName', to: '(removed)', path: `${path}.pageName` }); + delete next.pageName; + } + return next; + }; + return mapViewPayloads(stack, (payload, kind, path) => + kind === 'list' ? stripMount(payload, path) : payload); + }, + fixture: { + before: { + views: [{ + object: 'crm_contract', + // A complete page mount: both keys go, and the surviving `columns: []` + // is left alone — an empty column list is what the grid default + // derivation already reads as "no authored projection". + list: { type: 'page', pageName: 'contract_landing', columns: [] }, + listViews: { + // A pre-#13372 row: `pageName` beside a type that never read it. + strays: { type: 'grid', pageName: 'contract_landing', columns: ['name'] }, + // Neither key declared -> untouched, by reference. + all: { type: 'grid', columns: ['name'] }, + }, + }], + }, + after: { + views: [{ + object: 'crm_contract', + list: { columns: [] }, + listViews: { + strays: { type: 'grid', columns: ['name'] }, + all: { type: 'grid', columns: ['name'] }, + }, + }], + }, + expectedNotices: 3, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -9063,6 +9166,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly> = { kanban: 'kanban', diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__pageName.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__pageName.ts new file mode 100644 index 0000000000..175b3555ac --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ListView__pageName.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision +// batch #107 item 1, verbatim 「撤」). `ListView.pageName` named the published +// page a `type: 'page'` view was to mount. Only the spec half of #13216 ever +// landed: no renderer read the key, so the named page was never reached and the +// view drew an empty grid. Tombstoned with `retiredKey()` beside the +// `virtualScroll` tombstone already on this shape; the enum VALUE `'page'` went +// with it, carrying its own prescription on the `type` enum's error map. The +// surviving page mount is the app navigation item (`PageNavItem.pageName`), +// which is a different key on a different surface and has always rendered. D2: +// `view-page-mount-removed`. +export const entry = 'ui/ListView:pageName'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__pageName.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__pageName.ts new file mode 100644 index 0000000000..86db0aa0a4 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectListView__pageName.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision +// batch #107 item 1, verbatim 「撤」). `ObjectListView.pageName` named the published +// page a `type: 'page'` view was to mount. Only the spec half of #13216 ever +// landed: no renderer read the key, so the named page was never reached and the +// view drew an empty grid. Tombstoned with `retiredKey()` beside the +// `virtualScroll` tombstone already on this shape; the enum VALUE `'page'` went +// with it, carrying its own prescription on the `type` enum's error map. The +// surviving page mount is the app navigation item (`PageNavItem.pageName`), +// which is a different key on a different surface and has always rendered. D2: +// `view-page-mount-removed`. +export const entry = 'ui/ObjectListView:pageName'; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 811080f111..68af4dbe67 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5402,7 +5402,20 @@ const step18: MigrationStep = { 'loader — so `cache: { enabled: false }` switched nothing off. All three are retiredKey ' + 'tombstones registered in RETIRED_KEYS_BY_MAJOR[18] with one D3 semantic entry and no D2 ' + 'conversion (a manager config is no stack collection member); the rename is folded into ' + - 'the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key.', + 'the removal, so `cache.ttl` now prescribes deletion rather than a hop to a retired key. ' + + 'It also retires the `type: \'page\'` LIST-VIEW mount and its `pageName` binding (#17063, ' + + 'ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09 「撤」). The member was added so a ' + + 'view could render nothing of its own and delegate to an already-published page, but only ' + + 'the spec half landed: no renderer ever routed it — objectui\'s list-view switch shares its ' + + 'default arm with `grid` — so a page view drew an empty table where the page belonged, and ' + + 'the three parse refusals policing the binding policed a mount that never mounted anything. ' + + 'The enum VALUE carries its prescription on the `type` enum\'s own error map (an enum-value ' + + 'narrowing has no tombstone to hang one on, the `exportOptions` \'pdf\' precedent); ' + + '`pageName` is a retiredKey tombstone on both list-view doors. The D2 conversion STRIPS ' + + 'both keys rather than rewriting `type` to `\'grid\'`: `type` defaults to `grid` in the ' + + 'schema, so deleting it lands the row on exactly what it already rendered without this ' + + 'registry guessing a view type. The surviving page mount is the app navigation item ' + + '(`PageNavItem.pageName`), untouched.', conversionIds: [ 'field-malformed-scale-precision-removed', 'record-chatter-position-vocabulary', @@ -5427,6 +5440,7 @@ const step18: MigrationStep = { 'connector-health-and-trigger-durations-unit-in-key', 'memory-persistence-auto-save-interval-to-ms', 'turso-config-timeout-to-timeout-ms', + 'view-page-mount-removed', ], semantic: [ // One file per entry under `entries/semantic/`, concatenated here sorted by @@ -12730,6 +12744,17 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `element-input-target-variable-removed` (a page component IS a stack // collection member, unlike the `kernel/Manifest:loading` family). 'ui/ElementTextInputProps:targetVariable', + // #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision + // batch #107 item 1, verbatim 「撤」). `ListView.pageName` named the published + // page a `type: 'page'` view was to mount. Only the spec half of #13216 ever + // landed: no renderer read the key, so the named page was never reached and the + // view drew an empty grid. Tombstoned with `retiredKey()` beside the + // `virtualScroll` tombstone already on this shape; the enum VALUE `'page'` went + // with it, carrying its own prescription on the `type` enum's error map. The + // surviving page mount is the app navigation item (`PageNavItem.pageName`), + // which is a different key on a different surface and has always rendered. D2: + // `view-page-mount-removed`. + 'ui/ListView:pageName', // #11805 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-25, // decision-inbox batch 4: 「#11805 退役 defaultSort,不需要major」; the producer // half of objectui#5861, under the objectui#4869 「接受所有」 direction). @@ -12753,6 +12778,17 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // (wrap-and-rename to `sort: [pair]` when `sort` is absent; a pure lossless // delete when `sort` is present, since the fallback was never read then). 'ui/ObjectGridProps:defaultSort', + // #17063 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-09, decision + // batch #107 item 1, verbatim 「撤」). `ObjectListView.pageName` named the published + // page a `type: 'page'` view was to mount. Only the spec half of #13216 ever + // landed: no renderer read the key, so the named page was never reached and the + // view drew an empty grid. Tombstoned with `retiredKey()` beside the + // `virtualScroll` tombstone already on this shape; the enum VALUE `'page'` went + // with it, carrying its own prescription on the `type` enum's error map. The + // surviving page mount is the app navigation item (`PageNavItem.pageName`), + // which is a different key on a different surface and has always rendered. D2: + // `view-page-mount-removed`. + 'ui/ObjectListView:pageName', // #11027 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-22, ruled B: // retire + repair the redirect texts in the same change). The LAST carrier of // the `ResponsiveConfig` layout block, and the destination the diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 2c2a8e9139..bb4b733b51 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -1507,7 +1507,7 @@ describe('defineStack — ADR-0087 D2 conversion notices', () => { }); }); -describe('defineStack - `type: page` view → page cross-reference (#13216)', () => { +describe('defineStack — the RETIRED `type: page` view mount (#17063)', () => { const baseManifest = { id: 'com.example.test', name: 'test-project', @@ -1527,47 +1527,74 @@ describe('defineStack - `type: page` view → page cross-reference (#13216)', () kind: 'html' as const, source: '', }; - const mount = (pageName: string) => ({ type: 'page' as const, pageName, columns: [] }); - it('accepts a mount whose page the stack declares', () => { + // #13216 gave a list view a `type: 'page'` mount and gave + // `validateCrossReferences` a FOURTH page-resolving branch to check it with. + // #17063 retired both (ADR-0049 enforce-or-remove, maintainer ruling + // 2026-09-09, verbatim 「撤」): the render half was never built, so the branch + // resolved a reference for a mount that never mounted anything. The mount is + // now refused at PARSE, which runs BEFORE any cross-reference walk — so the + // branch has nothing left to resolve, and its absence is what these pins + // measure. The surviving three page references in that function (an app nav + // item's `pageName`, and a modal action's `target` at two rungs) keep their + // own `pageNames.size > 0` policy, pinned elsewhere in this file. + // + // ⛔ The cast is the pin's subject, not a workaround for it: `type: 'page'` + // and `pageName` are both UNTYPABLE on a list view now, which is the `tsc` + // half of the retirement. Spelled `as unknown as never` so this file's + // shrink-only `check:test-typecheck` ratchet sees no new signature. + const untypable = (body: Record) => body as unknown as never; + + it('REFUSES a page mount at parse, with the enum prescription — not a cross-reference error', () => { expect(() => defineStack({ manifest: baseManifest, objects: [account], pages: [dashboard], - views: [{ name: 'account', object: 'account', list: mount('account_dashboard') }], - })).not.toThrow(); + views: [{ + name: 'account', + object: 'account', + list: untypable({ type: 'page', pageName: 'account_dashboard', columns: [] }), + }], + })).toThrow(/'page' was removed from the list-view `type` enum/s); }); - it('detects a mount naming a page that does not exist', () => { - const config = { + it('REFUSES a page mount on `objects[].listViews.` too', () => { + expect(() => defineStack({ manifest: baseManifest, - objects: [account], pages: [dashboard], - views: [{ name: 'account', object: 'account', list: mount('ghost_dashboard') }], - }; - expect(() => defineStack(config)).toThrow('ghost_dashboard'); - expect(() => defineStack(config)).toThrow('cross-reference validation failed'); + objects: [{ + ...account, + listViews: { dash: untypable({ type: 'page', pageName: 'account_dashboard', columns: [] }) }, + }], + })).toThrow(/'page' was removed from the list-view `type` enum/s); }); - it('detects it on `objects[].listViews.` too', () => { - const config = { + it('REFUSES a lone `pageName` on a surviving view type, with its own prescription', () => { + expect(() => defineStack({ manifest: baseManifest, - objects: [{ ...account, listViews: { dash: mount('ghost_dashboard') } }], + objects: [account], pages: [dashboard], - }; - expect(() => defineStack(config)).toThrow('ghost_dashboard'); + views: [{ + name: 'account', + object: 'account', + list: untypable({ type: 'grid', pageName: 'account_dashboard', columns: ['name'] }), + }], + })).toThrow(/`view\.pageName` was removed/s); }); - // The deliberate size gate, shared with the nav / modal-action page checks in - // the same function: a stack declaring NO pages may be mounting one another - // package provides, so the build-time check stands down and lint's - // `validateViewPageRefs` is what speaks. Pinning it here keeps the three page - // references on ONE policy instead of three. - it('stands down when the stack declares no pages at all (lint reports it instead)', () => { + // The refusal does NOT depend on the stack declaring pages. The retired + // branch stood down when `pages` was empty (another package might provide the + // target); a parse refusal has no such question to ask, so a page mount is + // refused identically in a stack that declares no pages at all. + it('refuses identically in a stack that declares no pages', () => { expect(() => defineStack({ manifest: baseManifest, objects: [account], - views: [{ name: 'account', object: 'account', list: mount('provided_elsewhere') }], - })).not.toThrow(); + views: [{ + name: 'account', + object: 'account', + list: untypable({ type: 'page', pageName: 'provided_elsewhere', columns: [] }), + }], + })).toThrow(/'page' was removed from the list-view `type` enum/s); }); }); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 34043ef7ce..f1ce16abc6 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -2229,51 +2229,14 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } - // Validate `type: 'page'` list view → page references (#13216). - // - // The THIRD surface in this function that names a page — after an app nav - // item's `pageName` and a modal action's `target` — and it is checked the - // same way for the same reasons, deliberately rather than incidentally: one - // reference kind should not answer to three different build-time policies. - // So it carries the identical `pageNames.size > 0` gate, whose meaning is - // stated once at the modal-action block above ("when no pages are defined the - // target may be provided by a plugin"), and `@objectstack/lint`'s - // `validateViewPageRefs` is what speaks when that gate has switched the check - // off — exactly the division `validate-nav-target-refs` already documents for - // the nav twin. - // - // A `page`-typed view whose `pageName` resolves to nothing is a view that - // appears in the object's view switcher and renders nothing when opened: - // unlike every other view type it has no rows to fall back to, so there is no - // degraded-but-visible state to notice it by. `ListViewSchema`'s own - // refinement has already guaranteed the key is present and well-formed by the - // time this runs; existence is the one question left, and only a whole-stack - // walk can ask it. - const checkViewPageRef = (listView: unknown, where: string): void => { - if (!listView || typeof listView !== 'object') return; - const lv = listView as { type?: unknown; pageName?: unknown }; - if (lv.type !== 'page' || typeof lv.pageName !== 'string') return; - if (pageNames.size === 0 || pageNames.has(lv.pageName)) return; - errors.push( - `${where} mounts page '${lv.pageName}' which is not defined in pages.`, - ); - }; - if (config.views) { - for (const [i, view] of config.views.entries()) { - const viewLabel = view.name ? `View '${view.name}'` : `View[${i}]`; - checkViewPageRef(view.list, `${viewLabel} list`); - for (const [key, lv] of Object.entries(view.listViews ?? {})) { - checkViewPageRef(lv, `${viewLabel} listViews.${key}`); - } - } - } - if (config.objects) { - for (const obj of config.objects) { - for (const [key, lv] of Object.entries(obj.listViews ?? {})) { - checkViewPageRef(lv, `Object '${obj.name}' listViews.${key}`); - } - } - } + // [#17063] The `type: 'page'` list-view → page existence branch was REMOVED + // here with the mount itself (ADR-0049 enforce-or-remove, maintainer ruling + // 2026-09-09 「撤」). It was the THIRD surface in this function that named a + // page; the surviving two — an app nav item's `pageName` and a modal action's + // `target`, both below — are untouched, as is `pageNames` itself, which they + // share. A list view can no longer carry `pageName` at all (the key is a + // `retiredKey()` tombstone on `ListViewSchema`), so there is no reference + // left to resolve rather than a resolution left unchecked. if (config.actions) { for (const action of config.actions) { diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index a9b6e3b151..7850959e4d 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -53,7 +53,6 @@ import { fileURLToPath } from 'node:url'; import { ListViewSchema, ObjectListViewSchema, - checkListViewPageMount, checkListViewCalendarVisualization, } from './view.zod'; import { PageSchema, checkPageSourceCompleteness } from './page.zod'; @@ -172,26 +171,12 @@ const vectorOf = (run: (value: unknown) => IssueSig[], values: unknown[]): strin // Fixture matrices — one entry per distinct failure path, plus accepting paths // --------------------------------------------------------------------------- -const pageMountFixtures: Fixture[] = [ - { label: '`type: page` with no `pageName`', value: { type: 'page', columns: [] }, refusesAt: ['pageName'] }, - { - label: '`pageName` on a view that is not `type: page`', - value: { type: 'grid', pageName: 'sales_dashboard', columns: ['name'] }, - refusesAt: ['pageName'], - }, - { - label: 'a page mount declaring `columns`', - value: { type: 'page', pageName: 'sales_dashboard', columns: ['name'] }, - refusesAt: ['columns'], - }, - { - label: 'both failures at once — no `pageName` AND `columns`', - value: { type: 'page', columns: ['name'] }, - refusesAt: ['pageName', 'columns'], - }, - { label: 'a complete page mount', value: { type: 'page', pageName: 'sales_dashboard', columns: [] }, refusesAt: [] }, - { label: 'an ordinary grid', value: { type: 'grid', columns: ['name'] }, refusesAt: [] }, -]; +// [#17063] `pageMountFixtures` / `checkListViewPageMount` stood here. The check +// existed only to police the `type: 'page'` mount, and the mount was retired +// under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-09 「撤」) — so the +// export, its three refusal messages and this population went with it. Nothing +// weaker replaced them: the enum refuses the value by name and `pageName` is a +// `retiredKey()` tombstone, both pinned in `view.test.ts`. const calendarFixtures: Fixture[] = [ { @@ -249,14 +234,13 @@ const dateDefaultFixtures: Fixture[] = [ // --------------------------------------------------------------------------- const listViewExports: ExportUnderTest[] = [ - { name: 'checkListViewPageMount', check: checkListViewPageMount, fixtures: pageMountFixtures }, { name: 'checkListViewCalendarVisualization', check: checkListViewCalendarVisualization, fixtures: calendarFixtures }, ]; const MIRRORED: MirroredSchema[] = [ { name: 'ListViewSchema', schema: ListViewSchema, exports: listViewExports, cleanFixtures: [{ type: 'grid', columns: ['name'] }] }, - // `objects[].listViews.*` re-attaches the same two checks (zod 4 refuses - // `.omit()` on a refined object, so the door cannot inherit them). Pinned + // `objects[].listViews.*` re-attaches the same check (zod 4 refuses + // `.omit()` on a refined object, so the door cannot inherit it). Pinned // here too so a second copy cannot appear at the ADR-0047 authoring door. { name: 'ObjectListViewSchema', schema: ObjectListViewSchema, exports: listViewExports, cleanFixtures: [{ type: 'grid', columns: ['name'] }] }, { @@ -357,23 +341,27 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () const declarations = (src: string, name: string): number => src.match(new RegExp(`^\\s*(export )?function ${name}\\b`, 'gm'))?.length ?? 0; - it('view.zod.ts declares both exports and chains them onto ListViewShapeSchema for ListViewSchema', () => { + it('view.zod.ts declares the export and chains it onto ListViewShapeSchema for ListViewSchema', () => { const src = read('view.zod.ts'); - expect(src).toContain('export function checkListViewPageMount('); expect(src).toContain('export function checkListViewCalendarVisualization('); - // Exactly one declaration each — the counts below key on these names. - expect(declarations(src, 'checkListViewPageMount')).toBe(1); + // Exactly one declaration — the count below keys on this name. expect(declarations(src, 'checkListViewCalendarVisualization')).toBe(1); - // The mirrored door, exactly: shape → page-mount check → calendar check. + // The mirrored door, exactly: shape → calendar check. expect(src).toMatch( - /ListViewShapeSchema\s*\.superRefine\(checkListViewPageMount\)\s*\.superRefine\(checkListViewCalendarVisualization\)/, + /ListViewShapeSchema\s*\.superRefine\(checkListViewCalendarVisualization\)/, ); // Three doors (authoring terminal, `objects[].listViews.*`, the flattened - // overlay) attach each check — `viewDoorsCarryingPageMountCheck` in + // overlay) attach the check — `viewDoorsCarryingObjectLevelChecks` in // view.test.ts pins the behaviour; this pins that every attachment is the // export, by name, and none is an inline copy. - expect(attachments(src, 'checkListViewPageMount')).toBe(3); expect(attachments(src, 'checkListViewCalendarVisualization')).toBe(3); + // [#17063] `checkListViewPageMount` was retired with the `type: 'page'` + // mount it policed, so neither a declaration nor an attachment of it may + // return: a re-attachment would be a check with no rule left to enforce. + // Counted, not `toContain`-ed — the file's own tombstone docblock names the + // retired check in prose on purpose, and a mention is not a relapse. + expect(declarations(src, 'checkListViewPageMount')).toBe(0); + expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); it('page.zod.ts declares the export and attaches it to PageSchema', () => { @@ -397,7 +385,6 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () describe('`./index` (the `@objectstack/spec/ui` surface) exports the same function objects', () => { it.each([ - ['checkListViewPageMount', checkListViewPageMount], ['checkListViewCalendarVisualization', checkListViewCalendarVisualization], ['checkPageSourceCompleteness', checkPageSourceCompleteness], ['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue], @@ -406,4 +393,13 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi expect(typeof fn).toBe('function'); expect(fn.length).toBe(2); }); + + // [#17063] The retired member, from the same surface, in the same leg. A + // downstream mirror re-attaching a check it imports from here is the whole + // point of this file, so the barrel is where a relapse would first become + // reachable — the runtime namespace answers it, with the three survivors + // above as the lit control that the namespace is really populated. + it('no longer exports `checkListViewPageMount` — retired with the mount it policed', () => { + expect('checkListViewPageMount' in (ui as Record)).toBe(false); + }); }); diff --git a/packages/spec/src/ui/view.form.ts b/packages/spec/src/ui/view.form.ts index f8dc2cdb4b..bea0c5ab92 100644 --- a/packages/spec/src/ui/view.form.ts +++ b/packages/spec/src/ui/view.form.ts @@ -103,26 +103,12 @@ export const viewForm = defineForm({ visibleWhen: "data.type == 'chart'", fields: [{ field: 'chart', type: 'composite' }], }, - { - // [#13216] The `page` surface. Unlike every sibling block above, this one - // is a single binding rather than a composite: a `page` view delegates - // its whole rendering to the named page, so there is nothing else about - // it to configure here — every other control on this form describes how - // to draw ROWS, which a page view does not do. - name: 'page', - label: 'Page', - description: 'The published page this view mounts.', - visibleWhen: "data.type == 'page'", - fields: [ - { - field: 'pageName', - required: true, - helpText: - 'Name of an already-published page (lowercase snake_case). The page renders in place of ' - + 'the record list, so leave Columns empty.', - }, - ], - }, + // [#17063] The `page` section was REMOVED with the mount it configured + // (ADR-0049 enforce-or-remove, maintainer ruling 2026-09-09 「撤」). It bound + // the one input `pageName`, which is now a `retiredKey()` tombstone on + // `ListViewSchema` — a form input for an unwritable key is the false-compliant + // UI half of a retirement. To put a published page in front of users, author + // an app navigation item instead. { name: 'end_user_controls', label: 'End-user controls', diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index f58ae77cce..0edd90fb80 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3754,22 +3754,28 @@ describe('ViewSchema → JSON Schema derivation (Studio inspector path)', () => }); // ============================================================================ -// [#13216] `type: 'page'` — mounting an already-published page on an object view +// [#17063] The RETIRED `type: 'page'` mount — `'page'` and `pageName` are gone // ============================================================================ /** - * The three doors a list view is judged by. Named ONE place because the binding - * check is attached at three separate points (zod 4 refuses `.omit()` and - * key-overwriting `.extend()` on a refined object, so the overlay and - * object-scoped shapes are built from the unrefined shape and re-attach it) — - * and a missing re-attachment is invisible: the door keeps accepting, which - * reads as "no rule violated" rather than "no rule ran". + * The three doors a list view is judged by. Named ONE place because the + * object-level binding check is attached at three separate points (zod 4 + * refuses `.omit()` and key-overwriting `.extend()` on a refined object, so the + * overlay and object-scoped shapes are built from the unrefined shape and + * re-attach it) — and a missing re-attachment is invisible: the door keeps + * accepting, which reads as "no rule violated" rather than "no rule ran". * * The overlay entry is the one that matters most and is the easiest to lose: * it is `PUT /api/v1/meta/view`, the only door a Studio tenant or an MCP/AI * author has. + * + * [#17063] Renamed from `viewDoorsCarryingPageMountCheck`: the page-mount check + * that named it was retired with its mount, and the list is now used by the + * calendar-binding block below AND by the retirement pins directly under it — + * a retired VALUE and a retired KEY have to be refused at every door too, or a + * door that kept accepting them would be the same invisible hole one level up. */ -const viewDoorsCarryingPageMountCheck = [ +const viewDoorsCarryingObjectLevelChecks = [ ['ListViewSchema', (body: Record) => ListViewSchema.safeParse(body)], ['ObjectListViewSchema', (body: Record) => ObjectListViewSchema.safeParse(body)], [ @@ -3779,72 +3785,106 @@ const viewDoorsCarryingPageMountCheck = [ ], ] as const; -describe("ListViewSchema — the `page` view type (#13216)", () => { - it('accepts a page mount: `type: page` + `pageName` + empty columns', () => { - const parsed = ListViewSchema.parse({ type: 'page', pageName: 'sales_dashboard', columns: [] }); - expect(parsed.type).toBe('page'); - expect(parsed.pageName).toBe('sales_dashboard'); - }); +/** + * The retirement's whole value is that the old spelling now FAILS, carrying the + * prescription — so these pins assert the refusal at every door, and assert the + * message rather than only the boolean. A bare `.success === false` would stay + * green if the value were refused with zod's default "invalid option" text, + * which teaches nothing. + * + * `'page'` was an enum VALUE and `pageName` a KEY, and the two refusals travel + * on different mechanisms (an `error` map keyed on `issue.input` vs a + * `retiredKey()` tombstone), so each is pinned on its own. + */ +describe("ListViewSchema — the RETIRED `page` view type (#17063)", () => { + /** + * Walk `invalid_union` wrappers and return every issue, nested arms included + * — the same helper the calendar block below needs, for the same reason. + * + * ⚠️ The flatten is load-bearing at the OVERLAY door and nowhere else: on + * `ViewMetadataSchema` a shape-level refusal is raised inside a union BRANCH, + * so the top-level message is zod's "Invalid input" and the prescription sits + * one level down. Measured to be the pre-existing behaviour of EVERY + * `retiredKey()` tombstone on this shape (`virtualScroll`, `striped`, + * `bordered` all read identically), so it is the house behaviour of the door + * rather than anything this retirement introduces — the union-level dispatch + * that lifts such a message to the top (`exportOptionsPdfUnionError`, this + * file) is a per-case decision, and widening it to the whole tombstone family + * is its own question with its own measurement. + */ + const flatten = (issues: z.ZodIssue[]): z.ZodIssue[] => + issues.flatMap((i) => { + const nested = (i as unknown as { errors?: z.ZodIssue[][] }).errors; + return i.code === 'invalid_union' && Array.isArray(nested) + ? [i, ...flatten(nested.flat())] + : [i]; + }); - it('keeps every pre-existing view type accepting exactly as before', () => { + it('keeps every surviving view type accepting exactly as before', () => { const types = ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'chart', 'tree'] as const; for (const type of types) { expect(ListViewSchema.safeParse({ type, columns: ['name'] }).success, type).toBe(true); } }); - // The refusal that makes the enum member worth adding. Every OTHER view type - // degrades to a wrong-but-visible list when its binding is missing (which is - // why `checkViewCompleteness` only warns about those); a `page` view with no - // `pageName` has no rows to fall back to and renders nothing at all. - describe.each(viewDoorsCarryingPageMountCheck)('%s', (_label, parse) => { - it('REFUSES `type: page` with no `pageName`', () => { + describe.each(viewDoorsCarryingObjectLevelChecks)('%s', (_label, parse) => { + it("REFUSES `type: 'page'` by name, with the removal prescription", () => { const r = parse({ type: 'page', columns: [] }); expect(r.success).toBe(false); - const issue = (r as { error: z.ZodError }).error.issues - .find((i) => i.path.join('.').endsWith('pageName')); + const issue = flatten((r as { error: z.ZodError }).error.issues) + .find((i) => i.message.includes("'page' was removed from the list-view `type` enum")); expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); - expect(issue!.message).toContain('needs to say WHICH page'); + // The prescription names the surviving mount, not just the removal. + expect(issue!.message).toContain("`{ type: 'page', pageName: '' }`"); }); - it('REFUSES `pageName` on a view that is not `type: page` — the inert-key direction', () => { + it('REFUSES `pageName` with the tombstone prescription, not a bare unknown-key report', () => { const r = parse({ type: 'grid', pageName: 'sales_dashboard', columns: ['name'] }); expect(r.success).toBe(false); - const issue = (r as { error: z.ZodError }).error.issues - .find((i) => i.path.join('.').endsWith('pageName')); - expect(issue).toBeDefined(); - expect(issue!.message).toContain("only read by a `type: 'page'` view"); - }); - - it('REFUSES a page mount that also declares columns — they are never read', () => { - const r = parse({ type: 'page', pageName: 'sales_dashboard', columns: ['name'] }); - expect(r.success).toBe(false); - const issue = (r as { error: z.ZodError }).error.issues - .find((i) => i.path.join('.').endsWith('columns')); - expect(issue).toBeDefined(); - expect(issue!.message).toContain('columns: []'); + const issue = flatten((r as { error: z.ZodError }).error.issues) + .find((i) => i.message.includes('`view.pageName` was removed')); + expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); + expect(issue!.path.join('.')).toBe('pageName'); }); - it('accepts a well-formed page mount', () => { - expect(parse({ type: 'page', pageName: 'sales_dashboard', columns: [] }).success).toBe(true); + it('an ordinary grid view still parses — the lit control for the two refusals above', () => { + expect(parse({ type: 'grid', columns: ['name'] }).success).toBe(true); }); }); - // `pageName` carries `SnakeCaseIdentifierSchema` — the SAME grammar - // `PageSchema.name` is declared with — so the accepted set is exactly the set - // of strings that could name a page. A value outside it names nothing that - // can exist, and no collection lookup is needed to say so. - it('REFUSES a `pageName` outside the page-name grammar', () => { - const r = ListViewSchema.safeParse({ type: 'page', pageName: 'SalesDashboard', columns: [] }); + // An UNRELATED bad value must keep zod's own message: the error map is keyed + // on `issue.input` precisely so only the value that used to be legal gets the + // "was removed" text. Without this, a map that returned the prescription + // unconditionally would pass every pin above and mislead every other typo. + it('does not hand the removal prescription to an unrelated invalid `type`', () => { + const r = ListViewSchema.safeParse({ type: 'gridd', columns: ['name'] }); expect(r.success).toBe(false); - expect(r.success === false && r.error.issues.some((i) => i.path.join('.') === 'pageName')).toBe(true); + const messages = (r as { error: z.ZodError }).error.issues.map((i) => i.message).join('\n'); + expect(messages).not.toContain('was removed from the list-view `type` enum'); + }); + + // The prescription is the migration doc for whoever hits it, so it closes + // with the house `os migrate meta` sentence — pinned class-wide for the + // tombstone by `retired-key-migrate-sentence.test.ts`, and pinned here for + // the enum-value half, which that scanner also judges. + it('both prescriptions carry the pinned migrate sentence', () => { + const collect = (body: Record): string => { + const r = ListViewSchema.safeParse(body); + return r.success ? '' : r.error.issues.map((i) => i.message).join('\n'); + }; + for (const body of [{ type: 'page', columns: [] }, { type: 'grid', pageName: 'p', columns: [] }]) { + expect(collect(body)).toContain( + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ); + } }); // `columns` stays REQUIRED on the list arm. The union tells its list and form // overlay members apart by "no required `columns`, disjoint `type` enum" with - // the list arm tried first, so relaxing it to spare a page view the `[]` - // would let a flattened FORM overlay match the list arm and have its - // `sections` stripped. This pins the property the relaxation would break. + // the list arm tried first, so relaxing it would let a flattened FORM overlay + // match the list arm and have its `sections` stripped. The page mount was the + // one view type that made `columns: []` look like a wart worth relaxing; it + // is gone, and the property it would have broken is pinned here regardless. it('still tells a flattened FORM overlay apart from a list overlay', () => { const form = ViewMetadataSchema.safeParse({ name: 'crm_lead.edit', @@ -3857,9 +3897,8 @@ describe("ListViewSchema — the `page` view type (#13216)", () => { expect((form as { data: { sections?: unknown[] } }).data.sections).toHaveLength(1); }); - // `page` is deliberately NOT a visualization users can switch to: the - // switcher offers alternative ways to draw the same ROWS, and a page draws - // none. Pinned so the omission reads as a decision rather than an oversight. + // `page` was never a visualization users could switch to, and it must not + // become one on the way out either. it('does NOT add `page` to the visualization switcher whitelist', () => { expect(VisualizationTypeSchema.safeParse('page').success).toBe(false); }); @@ -3880,12 +3919,12 @@ describe("ListViewSchema — calendar in `appearance.allowedVisualizations` requ : [i]; }); - // The same three doors the page-mount check runs at — the check is attached - // at three separate points for the same zod-4 reason, and a missing - // re-attachment is invisible (the door keeps accepting, which reads as "no - // rule violated" rather than "no rule ran"). The overlay door is + // The same three doors, and since #17063 this is the ONLY object-level check + // they carry — it is attached at three separate points for the zod-4 reason, + // and a missing re-attachment is invisible (the door keeps accepting, which + // reads as "no rule violated" rather than "no rule ran"). The overlay door is // `PUT /api/v1/meta/view`, the one a Studio tenant or an MCP/AI author has. - describe.each(viewDoorsCarryingPageMountCheck)('%s', (_label, parse) => { + describe.each(viewDoorsCarryingObjectLevelChecks)('%s', (_label, parse) => { it("REFUSES 'calendar' in allowedVisualizations with no `calendar:` block, naming `calendar.startDateField`", () => { const r = parse({ type: 'grid', diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 42d9f961bc..34f111fcef 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1629,93 +1629,57 @@ const exportOptionsPdfUnionError = (issue: { input?: unknown }): string | undefi }; /** - * [#13216] The `type: 'page'` mount refusals, in BOTH directions. - * - * `page` is the one member of the view `type` enum that renders nothing of its - * own: every other member describes how to draw the object's ROWS, and degrades - * to a wrong-but-visible list when its config block is missing (which is why - * `checkViewCompleteness`'s `VIEW_BINDING_BLOCKS` treats those as WARNINGs — the - * renderer falls back to literal default field names). A `page` view has no - * rows to fall back to: without `pageName` there is no page to hand the page - * renderer, so the view is not "degraded", it is empty. Nothing weaker than a - * parse refusal is honest about that, and the refusal is why `page` is - * deliberately absent from `VIEW_BINDING_BLOCKS` — see the note there. - * - * The reverse direction matters just as much and is the cheaper mistake to - * make: `pageName` on a `grid` (or any non-`page`) view is accepted-and-ignored, - * the failure mode this file's `VIEW_HISTORY` exists to record and the one that - * reads as working to whoever authored it — human or model. - * - * ## Why `columns` is checked here and the other inert keys are not - * - * `columns` is the ONLY required key on {@link ListViewSchema}, so a `page` view - * cannot be authored without writing one, and the only truthful value is `[]`. - * Leaving that unchecked would mean the platform forces an author to write a - * value and then ignores what they wrote. The optional type-specific blocks - * (`kanban` on a grid view, `chart` on a tree view, …) are NOT checked, here or - * anywhere: tolerating a stale optional block is this schema's standing - * disposition, and narrowing it is a separate decision about every view type, - * not a rider on this one. - * - * ⛔ `columns` is NOT made optional to spare the author the `[]`. The list and - * form overlay arms of {@link ViewMetadataSchema} are told apart by exactly two - * properties — "no required `columns`, disjoint `type` enum" — and the list arm - * is tried FIRST, so an optional `columns` would let a flattened FORM overlay - * that carries no explicit `type` match the LIST arm, default to `'grid'`, and - * have its `sections` silently stripped by that arm's `.strip()`. + * [#17063] The retired `type: 'page'` list-view mount — prescriptions only. + * + * `page` was one member of the list-view `type` enum and `pageName` was its + * binding: the view was to render nothing of its own and delegate to the + * already-published page named by `pageName` (#13216, maintainer ruling + * 2026-08-29, direction 1). Only the spec half was ever built. No renderer + * routed the member — objectui's `ListView` switch shares its `default:` arm + * with `case 'grid'`, and `isListViewVisualization('page')` is false — so a + * page view drew an empty grid where the page was supposed to be, and the + * three parse refusals that used to police the binding + * (`checkListViewPageMount`) policed a mount that never mounted anything. + * + * Retired under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-09, + * decision batch #107 item 1, verbatim 「撤」), on the governing principle + * 「已发布零消费的能力不因沉没成本获得豁免」. What replaces it: publish the page + * and reach it from an app NAVIGATION item (`{ type: 'page', pageName }` on + * `PageNavItemSchema`) — a different key on a different surface, and the page + * mount that has always rendered. + * + * Two prescriptions, because this retirement removes two different KINDS of + * authorable thing and they travel on different channels: + * + * - {@link LIST_VIEW_PAGE_NAME_RETIRED} — `pageName` is a KEY, so it keeps a + * `retiredKey()` tombstone in the shape: `tsc` types it `never` and the + * parse raises the prescription instead of a bare unrecognized-key report. + * - {@link LIST_VIEW_TYPE_PAGE_RETIRED} — `'page'` was an enum VALUE, and an + * enum-value narrowing has no tombstone to hang a prescription on (the def + * survives, one value lighter). The 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 — the {@link LIST_VIEW_EXPORT_PDF_RETIRED} + * precedent in this same file, and `HookBodyCapability` / + * `object.managedBy: 'system'` before it. */ -const VIEW_PAGE_MOUNT_NEEDS_PAGE_NAME = - "A `type: 'page'` view mounts a published page and has no rows of its own, so it needs to say " - + 'WHICH page: declare `pageName`. Unlike every other view type there is no fallback rendering — ' - + "without it the view is blank. Shape: `{ type: 'page', pageName: '', columns: [] }`."; - -const VIEW_PAGE_NAME_NEEDS_PAGE_TYPE = - "`pageName` is only read by a `type: 'page'` view (it names the published page that view mounts). " - + 'On any other view type it is accepted and never applied — the view renders as its own type and ' - + "the page is never reached. Set `type: 'page'` to mount the page, or remove `pageName`."; - -const VIEW_PAGE_MOUNT_HAS_COLUMNS = - "A `type: 'page'` view renders the page named by `pageName`, not a column list — the declared " - + 'columns are never read. `columns` is required on every list view, so write the empty list: ' - + '`columns: []`. (To show records in columns, use a row-rendering view type — `grid` and its ' - + 'siblings — or let the page itself declare the list it wants.)'; - -/** - * The `type` ⇄ `pageName` binding check attached to {@link ListViewSchema}. - * - * Attached with `.superRefine` rather than being folded into the shape because - * it is a relation BETWEEN two keys. Zod 4 attaches refinements to the schema - * itself, so `.extend()` and `.omit()` carry it — which is what puts this check - * on the flattened runtime overlay arm ({@link ViewMetadataSchema} member 3, - * the door a Studio tenant or an MCP/AI author writes through) and on - * {@link ObjectListViewSchema} (`objects[].listViews.*`) without a second copy. - * - * Exported (#16489, the spec half of objectui#7715) so a downstream mirror - * that derives its schema from `ListViewSchema.shape` — which carries the - * FIELDS by reference and drops every object-level check — can re-attach - * exactly this rule with `.superRefine(checkListViewPageMount)` instead of - * re-implementing it. One function per refinement, no bundle: a mirror attaches - * the checks whose fields it carries. Every spec door attaches this same - * binding, so the export IS the check the schema runs — pinned in - * `object-refinement-check-exports.test.ts`. - */ -export function checkListViewPageMount( - view: { type?: string; pageName?: string; columns?: unknown }, - ctx: z.RefinementCtx, -): void { - const isPageMount = view.type === 'page'; - const hasPageName = typeof view.pageName === 'string' && view.pageName.length > 0; - - if (isPageMount && !hasPageName) { - ctx.addIssue({ code: 'custom', path: ['pageName'], message: VIEW_PAGE_MOUNT_NEEDS_PAGE_NAME }); - } - if (!isPageMount && hasPageName) { - ctx.addIssue({ code: 'custom', path: ['pageName'], message: VIEW_PAGE_NAME_NEEDS_PAGE_TYPE }); - } - if (isPageMount && Array.isArray(view.columns) && view.columns.length > 0) { - ctx.addIssue({ code: 'custom', path: ['columns'], message: VIEW_PAGE_MOUNT_HAS_COLUMNS }); - } -} +const LIST_VIEW_TYPE_PAGE_RETIRED = + "'page' was removed from the list-view `type` enum in @objectstack/spec 17.5.0 " + + '(ADR-0049 enforce-or-remove) — the delegating mount was declared on the spec side and never ' + + 'built, so no renderer ever routed the member: a page view fell through to the grid branch and ' + + 'drew an empty table where the page was supposed to be. Delete the member and pick a view type ' + + 'that draws rows (`grid` and its siblings); to put a published page in front of users, give the ' + + "app a navigation item instead — `{ type: 'page', pageName: '' }` under the app's " + + '`navigation`, which 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.'; + +const LIST_VIEW_PAGE_NAME_RETIRED = + '`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: '' }` 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.'; const VIEW_CALENDAR_ALLOWED_NEEDS_START_DATE = "`appearance.allowedVisualizations` includes 'calendar', so end users can switch this view to a " @@ -1746,15 +1710,16 @@ const VIEW_CALENDAR_ALLOWED_NEEDS_START_DATE = * another visualization has the same shape is a separate finding to measure * first, not a rider here (the #13748 ruling says so in those words). * - * Attached with `.superRefine` at the same three doors as - * {@link checkListViewPageMount}, for the same zod-4 reason (refinements block - * `.omit()`/key-overwriting `.extend()`, so derived shapes re-attach): the - * authoring terminal, `objects[].listViews.*`, and the flattened runtime - * overlay arm — the door a Studio tenant or an MCP/AI author writes through. + * Attached with `.superRefine` at all three list-view doors, for the zod-4 + * reason that refinements block `.omit()`/key-overwriting `.extend()` so + * derived shapes re-attach: the authoring terminal, `objects[].listViews.*`, + * and the flattened runtime overlay arm — the door a Studio tenant or an + * MCP/AI author writes through. (#17063 retired the page-mount check that used + * to ride the same three attachment points; this one is now the only + * object-level binding check the list-view doors carry.) * - * Exported (#16489, the spec half of objectui#7715) for the same reason as - * {@link checkListViewPageMount}: a mirror built from `ListViewSchema.shape` - * drops this check and re-attaches it with + * Exported (#16489, the spec half of objectui#7715): a mirror built from + * `ListViewSchema.shape` drops this check and re-attaches it with * `.superRefine(checkListViewCalendarVisualization)` — the measured 17.3.0 * gap was exactly this rule, accepted by objectui's authoring door and refused * by the spec's publish door. Every spec door attaches this same binding, so @@ -1811,8 +1776,8 @@ export function checkListViewCalendarVisualization( * containing refinements"`, thrown at construction), and * {@link ObjectListViewSchema} is built by omitting `userFilters` from this * shape. So the shape stays refinement-free and BOTH terminals attach - * {@link checkListViewPageMount} themselves — one check function, two - * attachment points, no second copy of the rule. + * {@link checkListViewCalendarVisualization} themselves — one check function, + * two attachment points, no second copy of the rule. * * ⛔ Not exported, deliberately: a top-level EXPORTED schema binding mints a * new protocol def in `json-schema.manifest/` and a full set of ratcheted @@ -1835,6 +1800,12 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ }, { name: SnakeCaseIdentifierSchema.optional().describe('Internal view name (lowercase snake_case)'), label: I18nLabelSchema.optional(), // Display label override (supports i18n) + // [#17063] `'page'` was REMOVED here (ADR-0049 enforce-or-remove). This is an + // enum-VALUE narrowing, so there is no `retiredKey()` tombstone to hang the + // prescription on — the enum's own error map carries it + // ({@link LIST_VIEW_TYPE_PAGE_RETIRED}), keyed on `issue.input` so only the + // value which used to be legal gets the "was removed" message. Every other + // invalid value keeps zod's default message. type: z.enum([ 'grid', // Standard Data Table 'kanban', // Board / Columns @@ -1844,9 +1815,10 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ 'gantt', // Project Timeline 'map', // Geospatial 'chart', // Aggregate visualisation - 'tree', // Self-referencing hierarchy (tree-grid) - 'page' // Mount an already-published `page` — see `pageName` below - ]).default('grid'), + 'tree' // Self-referencing hierarchy (tree-grid) + ], { + error: (issue) => (issue.input === 'page' ? LIST_VIEW_TYPE_PAGE_RETIRED : undefined), + }).default('grid'), /** Data Source Configuration */ data: ViewDataSchema.optional().describe('Data source configuration (defaults to "object" provider)'), @@ -1943,31 +1915,20 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ tree: TreeConfigSchema.optional().describe('Tree/hierarchy configuration — applies when the view renders as a tree layout'), /** - * The published `page` a `type: 'page'` view mounts. + * [#17063] REMOVED — the `type: 'page'` mount this key named was never + * built. Tombstoned rather than deleted so the removal is audible in both + * channels an upgrading author hits: `tsc` (the input type is `never`) and + * the parse ({@link LIST_VIEW_PAGE_NAME_RETIRED} instead of a bare + * unrecognized-key report). The reference itself is not gone from the + * platform — `PageNavItemSchema.pageName` is the surviving spelling, on the + * app navigation item, and it is the page mount that has always rendered. * - * Named `pageName` because that is what a page reference is already called on - * every other surface that carries one — `PageNavItemSchema.pageName` in - * `app.zod.ts`, the collection it resolves against (`stack.pages`), and the - * lint rule that resolves it (`nav-target-unresolved`). A second spelling for - * the same reference would split the one thing an author has to learn. - * - * Grammar is {@link SnakeCaseIdentifierSchema} — the SAME schema - * `PageSchema.name` is declared with — rather than the bare `z.string()` - * the nav item uses, so the set of values this key accepts is exactly the set - * of strings that could name a page. A `pageName` outside that grammar names - * nothing that can exist, and saying so at parse is cheaper than resolving it - * against a collection. (The nav item stays a bare string: its targets may be - * `${…}`-interpolated at render time, which a view mount has no path to.) - * - * Existence of the target is a separate question with its own answers, on the - * doors that can see the collection: `defineStack`'s `validateCrossReferences` - * (build-time, throws) and `validateViewPageRefs` in `@objectstack/lint` - * (the CLI commands and the runtime publish gate). + * The two existence checks this key used to feed went with it: the + * `validateCrossReferences` branch in `stack.zod.ts` and `validateViewPageRefs` + * in `@objectstack/lint`. The nav item's own resolution + * (`validate-nav-target-refs`) is untouched. */ - pageName: SnakeCaseIdentifierSchema.optional().describe( - "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: retiredKey(LIST_VIEW_PAGE_NAME_RETIRED), /** View Metadata (Airtable-style view management) */ description: I18nLabelSchema.optional().describe('View description for documentation/tooltips'), @@ -2128,14 +2089,14 @@ const ListViewShapeSchema = lazySchema(() => strictObject({ /** * List View Schema (Expanded) — {@link ListViewShapeSchema} plus the - * `type: 'page'` ⇄ `pageName` binding check and the * `allowedVisualizations` ⇄ `calendar` binding check. See that shape for why - * shape and checks are separate bindings, and {@link checkListViewPageMount} / - * {@link checkListViewCalendarVisualization} for what each check refuses. + * shape and checks are separate bindings, and + * {@link checkListViewCalendarVisualization} for what the check refuses. + * (#17063 removed the `type: 'page'` ⇄ `pageName` binding check with the mount + * it policed.) */ export const ListViewSchema = lazySchema(() => ListViewShapeSchema - .superRefine(checkListViewPageMount) .superRefine(checkListViewCalendarVisualization)); /** @@ -3712,12 +3673,10 @@ export const ObjectListViewSchema = lazySchema(() => ListViewShapeSchema.omit({ userFilters: true }) .extend({ userFilters: ObjectUserFiltersSchema.optional() }) // Derived from the UNREFINED shape (zod 4 refuses `.omit()` on a refined - // object), so the binding checks are re-attached here rather than - // inherited. Dropping these lines would leave `objects[].listViews.*` — - // the ADR-0047 authoring surface — as the one door where a `page` view - // with no `pageName`, or a calendar-enabled view with no `calendar:` - // block, parses clean. - .superRefine(checkListViewPageMount) + // object), so the binding check is re-attached here rather than inherited. + // Dropping this line would leave `objects[].listViews.*` — the ADR-0047 + // authoring surface — as the one door where a calendar-enabled view with + // no `calendar:` block parses clean. .superRefine(checkListViewCalendarVisualization)); /** @@ -4624,13 +4583,12 @@ const ListViewOverlayWireSchema = lazySchema(() => // [#13216] Built from {@link ListViewShapeSchema}, not {@link ListViewSchema}: // `flattenedViewOverlayFields()` re-declares `name` and `label`, and zod 4 // refuses to overwrite a key on an object that carries refinements. So the - // binding checks are re-attached AFTER `.strip()` instead of inherited — this + // binding check is re-attached AFTER `.strip()` instead of inherited — this // is the runtime write door (`PUT /api/v1/meta/view`), the one an MCP/AI // author reaches, so it is the last place the refusals may go missing. - // `viewDoorsCarryingPageMountCheck` in `view.test.ts` fails if any of the + // `viewDoorsCarryingObjectLevelChecks` in `view.test.ts` fails if any of the // three attachment points is dropped. ListViewShapeSchema.extend(flattenedViewOverlayFields()).strip() - .superRefine(checkListViewPageMount) .superRefine(checkListViewCalendarVisualization), ); diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index d33297243f..7b9c2cff12 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -46,7 +46,6 @@ App navigation, Dashboards, Reports, and Actions. | `map` | Geospatial records with `location` fields | | `chart` | Aggregate visualisation over the object (mini chart view) | | `tree` | Self-referencing hierarchy (tree-grid) | -| `page` | Mounts a published Page (`pageName`); no rows of its own | ### Form Views diff --git a/skills/objectstack-ui/references/react-blocks.md b/skills/objectstack-ui/references/react-blocks.md index d7af9b769b..86df7da99e 100644 --- a/skills/objectstack-ui/references/react-blocks.md +++ b/skills/objectstack-ui/references/react-blocks.md @@ -66,7 +66,7 @@ Server-connected object table with toolbar and switchable visualizations (grid/k | `onRowClick` | `(record) => void` | callback | | Called with the clicked row's record — the hook for master/detail. | | `onNavigate` | `(recordId, action: 'view' \| 'edit') => void` | callback | | Called for page-level navigation. | | `columns` | `string[] \| object[]` | data | ✓ | Fields to display as columns | -| `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree' \| 'page'` | data | | | +| `type` | `'grid' \| 'kanban' \| 'gallery' \| 'calendar' \| 'timeline' \| 'gantt' \| 'map' \| 'chart' \| 'tree'` | data | | | | `sort` | `string \| object[]` | data | | | | `searchableFields` | `string[]` | data | | Fields enabled for search | | `userFilters` | `object` | data | | End-user quick-filter bar: dropdown/toggle fields or tab presets. Omit to let the renderer derive filters from select/boolean fields | From 1e937111c14c19fd4fc28cfe7872e28786d9b18a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 04:23:12 +0000 Subject: [PATCH 2/3] chore(i18n): regenerate metadata-form bundles after the page-mount retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../src/apps/translations/en.metadata-forms.generated.ts | 8 -------- .../apps/translations/es-ES.metadata-forms.generated.ts | 8 -------- .../apps/translations/es-ES.source-hashes.generated.ts | 4 ---- .../apps/translations/ja-JP.metadata-forms.generated.ts | 8 -------- .../apps/translations/ja-JP.source-hashes.generated.ts | 4 ---- .../apps/translations/zh-CN.metadata-forms.generated.ts | 8 -------- .../apps/translations/zh-CN.source-hashes.generated.ts | 4 ---- 7 files changed, 44 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 0fa7e43b08..a79b93db94 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -628,10 +628,6 @@ export const enMetadataForms: NonNullable = { label: "Chart", description: "Chart-specific configuration." }, - page: { - label: "Page", - description: "The published page this view mounts." - }, end_user_controls: { label: "End-user controls", description: "What end users can do on this view — quick filters, filter tabs, visualization switching (ADR-0047, Airtable Interface parity)." @@ -713,10 +709,6 @@ export const enMetadataForms: NonNullable = { chart: { label: "Chart" }, - pageName: { - label: "Page Name", - helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." - }, userFilters: { label: "User Filters", helpText: "Quick-filter bar: element style (dropdown / tabs / toggle) + exposed fields or tab presets" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 1bb4cf1bfb..4ed2139bb9 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -628,10 +628,6 @@ export const esESMetadataForms: NonNullable = label: "Gráfico", description: "Configuración específica de gráfico." }, - page: { - label: "Page", - description: "The published page this view mounts." - }, end_user_controls: { label: "Controles de usuario final", description: "Lo que los usuarios finales pueden hacer en esta vista: filtros rápidos, pestañas de filtro, cambio de visualización (ADR-0047, paridad con Airtable Interface)." @@ -713,10 +709,6 @@ export const esESMetadataForms: NonNullable = chart: { label: "Gráfico" }, - pageName: { - label: "Page Name", - helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." - }, userFilters: { label: "Filtros de usuario", helpText: "Barra de filtros rápidos: estilo de elemento (desplegable / pestañas / interruptor) + campos expuestos o preajustes de pestañas" diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index 4b3fb11f70..936f4faa00 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -205,10 +205,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "metadataForms.report.sections.dataset_binding.label": "34d97a3093d6fc56", "metadataForms.seed.description": "c22921feb0f06273", "metadataForms.seed.label": "60e0e22a54230cab", - "metadataForms.view.fields.pageName.helpText": "bd907125cd7554b6", - "metadataForms.view.fields.pageName.label": "6b8f40b2f3beb133", - "metadataForms.view.sections.page.description": "973556bc755dcfef", - "metadataForms.view.sections.page.label": "a65b2d4829365115", "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 004730fb28..553cc8987d 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -628,10 +628,6 @@ export const jaJPMetadataForms: NonNullable = label: "チャート", description: "チャート専用設定。" }, - page: { - label: "Page", - description: "The published page this view mounts." - }, end_user_controls: { label: "エンドユーザー操作", description: "このビューでエンドユーザーが行える操作——クイックフィルター、フィルタータブ、可視化切り替え(ADR-0047、Airtable Interface 互換)。" @@ -713,10 +709,6 @@ export const jaJPMetadataForms: NonNullable = chart: { label: "チャート" }, - pageName: { - label: "Page Name", - helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." - }, userFilters: { label: "ユーザーフィルター", helpText: "クイックフィルターバー:要素スタイル(ドロップダウン / タブ / トグル)+ 公開フィールドまたはタブプリセット" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts index 0158495444..0173a66004 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts @@ -205,10 +205,6 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "metadataForms.report.sections.dataset_binding.label": "34d97a3093d6fc56", "metadataForms.seed.description": "c22921feb0f06273", "metadataForms.seed.label": "60e0e22a54230cab", - "metadataForms.view.fields.pageName.helpText": "bd907125cd7554b6", - "metadataForms.view.fields.pageName.label": "6b8f40b2f3beb133", - "metadataForms.view.sections.page.description": "973556bc755dcfef", - "metadataForms.view.sections.page.label": "a65b2d4829365115", "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index ac0c3898ca..fa6dab918b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -628,10 +628,6 @@ export const zhCNMetadataForms: NonNullable = label: "图表配置", description: "图表专属配置" }, - page: { - label: "Page", - description: "The published page this view mounts." - }, end_user_controls: { label: "终端用户控制", description: "终端用户在此视图上可执行的操作——快速筛选、筛选标签页、可视化切换(ADR-0047,对标 Airtable Interface)。" @@ -713,10 +709,6 @@ export const zhCNMetadataForms: NonNullable = chart: { label: "图表" }, - pageName: { - label: "Page Name", - helpText: "Name of an already-published page (lowercase snake_case). The page renders in place of the record list, so leave Columns empty." - }, userFilters: { label: "用户筛选器", helpText: "快速筛选栏:控件样式(下拉 / 标签页 / 开关)+ 暴露的字段或标签页预设" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts index 00ed074b43..4df2457671 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts @@ -172,10 +172,6 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "metadataForms.report.sections.dataset_binding.label": "34d97a3093d6fc56", "metadataForms.seed.description": "c22921feb0f06273", "metadataForms.seed.label": "60e0e22a54230cab", - "metadataForms.view.fields.pageName.helpText": "bd907125cd7554b6", - "metadataForms.view.fields.pageName.label": "6b8f40b2f3beb133", - "metadataForms.view.sections.page.description": "973556bc755dcfef", - "metadataForms.view.sections.page.label": "a65b2d4829365115", "objects.sys_account._actions.link_social.params.provider.options.apple": "cfdc41e15ed6699b", "objects.sys_account._actions.link_social.params.provider.options.discord": "12f931cc062e76ae", "objects.sys_account._actions.link_social.params.provider.options.facebook": "7eea009178f5b807", From 59b2e5f0d29828619b9ac6fc304d56158610057c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 05:57:15 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the two os-regen artifacts the merge driver dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- packages/spec/api-surface/ui.json | 4 ++++ packages/spec/export-origins/ui.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index a160acecf0..80ed0655ba 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -138,6 +138,7 @@ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema (const)", "ElementDataSource (type)", + "ElementDataSourceParsed (type)", "ElementDataSourceSchema (const)", "ElementFilterPropsSchema (const)", "ElementFormPropsSchema (const)", @@ -251,6 +252,7 @@ "NotificationType (type)", "NotificationTypeSchema (const)", "ObjectCalendarProps (type)", + "ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema (const)", "ObjectFormProps (type)", "ObjectFormPropsSchema (const)", @@ -258,11 +260,13 @@ "ObjectGridPropsParsed (type)", "ObjectGridPropsSchema (const)", "ObjectKanbanProps (type)", + "ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema (const)", "ObjectListViewSchema (const)", "ObjectMasterDetailFormProps (type)", "ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps (type)", + "ObjectMetricPropsParsed (type)", "ObjectMetricPropsSchema (const)", "ObjectNavItem (type)", "ObjectNavItemParsed (type)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 11d27694e9..1973af384e 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -134,6 +134,7 @@ "EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT": "src/ui/expression-bindable-text-keys.zod.ts#EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT (const)", "ElementButtonPropsSchema": "src/ui/component.zod.ts#ElementButtonPropsSchema (const)", "ElementDataSource": "src/ui/page.zod.ts#ElementDataSource (type)", + "ElementDataSourceParsed": "src/ui/page.zod.ts#ElementDataSourceParsed (type)", "ElementDataSourceSchema": "src/ui/page.zod.ts#ElementDataSourceSchema (const)", "ElementFilterPropsSchema": "src/ui/component.zod.ts#ElementFilterPropsSchema (const)", "ElementFormPropsSchema": "src/ui/component.zod.ts#ElementFormPropsSchema (const)", @@ -247,6 +248,7 @@ "NotificationType": "src/ui/notification.zod.ts#NotificationType (type)", "NotificationTypeSchema": "src/ui/notification.zod.ts#NotificationTypeSchema (const)", "ObjectCalendarProps": "src/ui/component.zod.ts#ObjectCalendarProps (type)", + "ObjectCalendarPropsParsed": "src/ui/component.zod.ts#ObjectCalendarPropsParsed (type)", "ObjectCalendarPropsSchema": "src/ui/component.zod.ts#ObjectCalendarPropsSchema (const)", "ObjectFormProps": "src/ui/component.zod.ts#ObjectFormProps (type)", "ObjectFormPropsSchema": "src/ui/component.zod.ts#ObjectFormPropsSchema (const)", @@ -254,11 +256,13 @@ "ObjectGridPropsParsed": "src/ui/component.zod.ts#ObjectGridPropsParsed (type)", "ObjectGridPropsSchema": "src/ui/component.zod.ts#ObjectGridPropsSchema (const)", "ObjectKanbanProps": "src/ui/component.zod.ts#ObjectKanbanProps (type)", + "ObjectKanbanPropsParsed": "src/ui/component.zod.ts#ObjectKanbanPropsParsed (type)", "ObjectKanbanPropsSchema": "src/ui/component.zod.ts#ObjectKanbanPropsSchema (const)", "ObjectListViewSchema": "src/ui/view.zod.ts#ObjectListViewSchema (const)", "ObjectMasterDetailFormProps": "src/ui/component.zod.ts#ObjectMasterDetailFormProps (type)", "ObjectMasterDetailFormPropsSchema": "src/ui/component.zod.ts#ObjectMasterDetailFormPropsSchema (const)", "ObjectMetricProps": "src/ui/component.zod.ts#ObjectMetricProps (type)", + "ObjectMetricPropsParsed": "src/ui/component.zod.ts#ObjectMetricPropsParsed (type)", "ObjectMetricPropsSchema": "src/ui/component.zod.ts#ObjectMetricPropsSchema (const)", "ObjectNavItem": "src/ui/app.zod.ts#ObjectNavItem (type)", "ObjectNavItemParsed": "src/ui/app.zod.ts#ObjectNavItemParsed (type)",