From 6143a1201045920c80bfa4d2897d8b445d0773b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:36:50 +0000 Subject: [PATCH 1/3] feat(spec): strict element schemas for Field.inlineColumns / relatedListColumns (#9227) inlineColumns: strict name-keyed InlineGridColumnSchema mirroring the objectui grid renderer's measured reads (GridField.tsx GridColumn, #3951); the retired field spelling is refused with the prescription naming name. relatedListColumns: child field-name strings only (the only authored form in-repo; the renderer hydrates strings fully). Liveness ledger drilled for the 20 column child keys; showcase invoice fixture respelled field -> name; regenerated api-surface, export-origins, authorable-surface, json-schema.manifest, references docs, strictness-ledger counts, liveness counts; minor changeset per the #9221 launch-window convention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/inline-related-columns-strict.md | 38 +++ content/docs/references/data/field.mdx | 38 ++- content/docs/references/index.mdx | 10 +- ...07-unknown-key-strictness-ledger.counts.md | 16 +- .../src/data/objects/invoice.object.ts | 27 +- packages/spec/api-surface/data.json | 2 + packages/spec/authorable-surface/data.json | 20 ++ packages/spec/export-origins/data.json | 2 + packages/spec/json-schema.manifest/data.json | 1 + packages/spec/liveness/field.json | 94 ++++++- packages/spec/liveness/state-counts.md | 4 +- packages/spec/src/data/field.zod.ts | 112 +++++++- .../src/data/inline-related-columns.test.ts | 251 ++++++++++++++++++ 13 files changed, 576 insertions(+), 39 deletions(-) create mode 100644 .changeset/inline-related-columns-strict.md create mode 100644 packages/spec/src/data/inline-related-columns.test.ts diff --git a/.changeset/inline-related-columns-strict.md b/.changeset/inline-related-columns-strict.md new file mode 100644 index 0000000000..64dca93db6 --- /dev/null +++ b/.changeset/inline-related-columns-strict.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): strict element schemas for `Field.inlineColumns` and `Field.relatedListColumns` (#9227) + +**BREAKING** accept-set narrowing on a published authoring surface, landing +after the v17.0.0 cut (the lockstep launch-window convention ships it as +`minor`, the #9221/#9250 precedent). + +Both keys were `z.array(z.any())`: every column object validated — right keys, +wrong keys, misspelled keys, empty objects — so a mis-keyed column published +clean and surfaced only in the browser, as a grid with the right row count and +every cell blank (the objectui#3951 failure, reachable from the authoring side). + +- `inlineColumns` entries are now `InlineGridColumnSchema` (exported): a + strict, `name`-keyed column mirroring the objectui inline-grid renderer's + measured reads — `name` (required), `label?`, `type?`, `width?`, `required?`, + `options?`, `prefix?`, `step?`, `reference?`, `displayField?`, `idField?`, + `multiple?`, `accept?`, `defaultHidden?`, `computed?`, `expr?`, `scale?`, + `autofill?`, `readonlyWhen?`, `requiredWhen?`. Unknown keys are a named + rejection at publish time; the retired `field` spelling is refused with the + prescription naming `name` (objectui#3951 aligned the widget to `name` with + deliberately no tolerant alias). `expr` is the grid evaluator's BARE + arithmetic string — a CEL envelope there is refused. Identity-only entries + (`{ name: 'quantity' }`) remain the recommended form: objectui's + `hydrateColumns` fills everything else from the child object's fields. +- `relatedListColumns` entries are now child FIELD-NAME STRINGS (e.g. + `['name', 'status']`) — the only authored form in-repo and the only form the + related-list renderer hydrates fully (labels, cell types and formatting + derive from the child object's field definitions); the page-block sibling + `record:related_list.columns` is the same strings-only shape. A column + object is refused with a prescription pointing at the child fields. + +Migration: respell `{ field: 'x' }` inline-grid columns as `{ name: 'x' }`; +replace related-list column objects with the child field name string. The one +in-repo usage (`examples/app-showcase` invoice line items) is migrated in this +change. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index e28084fe3f..8bb247abe5 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -14,8 +14,8 @@ Field Type Enum ## TypeScript Usage ```typescript -import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; -import type { CurrencyConfig, CurrencyValue, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; +import { CurrencyConfigSchema, CurrencyValueSchema, FieldSchema, FieldMaskingKeepSchema, FieldMaskingRuleSchema, FieldType, InlineGridColumnSchema, LocationCoordinatesSchema, SelectOptionSchema, UniqueScopeSchema } from '@objectstack/spec/data'; +import type { CurrencyConfig, CurrencyValue, Field, FieldMaskingKeep, FieldMaskingRule, FieldType, InlineGridColumn, LocationCoordinates, SelectOption, UniqueScope } from '@objectstack/spec/data'; // Validate data const result = CurrencyConfigSchema.parse(data); @@ -79,11 +79,11 @@ const result = CurrencyConfigSchema.parse(data); | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | | **inlineEdit** | `boolean \| Enum<'grid' \| 'form'>` | optional | Edit these child records inline within the parent's form (atomic master-detail). true = auto-pick grid/form by child shape; 'grid' = editable line-item grid; 'form' = list + per-row full form. | | **inlineTitle** | `string` | optional | Title for the inline master-detail grid | -| **inlineColumns** | `any[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted) | +| **inlineColumns** | `{ name: string; label?: string; type?: Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>; width?: number; … }[]` | optional | Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column (`{ name, label?, type?, … }` — objectui GridColumn, #3951); identity-only entries (`{ name }`) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse. | | **inlineAmountField** | `string` | optional | Numeric child field summed for the inline grid total | | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | -| **relatedListColumns** | `any[]` | optional | Explicit columns for the detail-page related list (derived from the child object when omitted) | +| **relatedListColumns** | `string[]` | optional | Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse. | | **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | | **displayField** | `string` | optional | Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title). | | **descriptionField** | `string` | optional | Secondary field shown under the label in the quick-select popover. | @@ -274,6 +274,36 @@ Allowed Values: `phone`, `id_card`, `bank_account`, `email`, `name` * `vector` +--- + +## InlineGridColumn + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **name** | `string` | ✅ | Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name, #3951). The retired `field` spelling is refused. | +| **label** | `string` | optional | Column header; defaults to the child field's label via hydration. | +| **type** | `Enum<'text' \| 'number' \| 'currency' \| 'date' \| 'datetime' \| 'time' \| 'select' \| 'lookup' \| 'file'>` | optional | Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself. | +| **width** | `number` | optional | Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed). | +| **required** | `boolean` | optional | Cell is flagged inline-invalid while empty. Computed columns are never required. | +| **options** | `{ label: string; value: string }[]` | optional | Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`. | +| **prefix** | `string` | optional | Currency symbol rendered inside a `currency` cell (default '¥'). | +| **step** | `number` | optional | Input step for numeric cells. | +| **reference** | `string` | optional | Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`. | +| **displayField** | `string` | optional | Label field shown for a picked lookup record. | +| **idField** | `string` | optional | Id field stored for a picked lookup record. | +| **multiple** | `boolean` | optional | Multi-value column: multi-record lookup, or multi-file upload cell. | +| **accept** | `string[]` | optional | Accepted MIME types / extensions for a `file` cell's picker (e.g. ['image/*', '.pdf']); omit to accept anything. | +| **defaultHidden** | `boolean` | optional | Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden. | +| **computed** | `boolean` | optional | Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row. | +| **expr** | `string` | optional | Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here. | +| **scale** | `integer` | optional | Decimal places to round a computed numeric/currency result to. | +| **autofill** | `boolean` | optional | For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable. | +| **readonlyWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`). | +| **requiredWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`. | + + --- ## LocationCoordinates diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index ebe2a3e2e7..d8751c9f21 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1584 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [API Protocol](/docs/references/api) | 28 | 413 | REST/GraphQL contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 68 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Cloud Protocol](/docs/references/cloud) | 11 | 94 | Environments, packages and versions, marketplace, developer portal, tenancy. | -| [Data Protocol](/docs/references/data) | 29 | 165 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 29 | 166 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 27 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 31 | 176 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -33,7 +33,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 160 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1584** | 14 protocol modules | +| **Total** | **198** | **1585** | 14 protocol modules | --- @@ -146,7 +146,7 @@ Environments, packages and versions, marketplace, developer portal, tenancy. ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 165 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **29 pages, 166 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -170,7 +170,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`driver/turso.zod.ts`](/docs/references/data/driver-turso) | `TursoConfig`, `TursoTransportMode` | | [`external-catalog.zod.ts`](/docs/references/data/external-catalog) | `ExternalCatalog`, `ExternalColumn`, `ExternalTable` | | [`feed.zod.ts`](/docs/references/data/feed) | `FeedFilterMode`, `FeedItemType` | -| [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | +| [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | | [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | | [`filter.zod.ts`](/docs/references/data/filter) | `EqualityOperator`, `FieldReference`, `FilterArray`, `FilterCondition`, `QueryFilter`, `SetOperator`, `SpecialOperator`, `StringOperator` | | [`hook.zod.ts`](/docs/references/data/hook) | `HookContext`, `HookEvent` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index bf0341a8a7..b99cc67c97 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 440 | +| Object sites in them | 442 | | Still-open (strip) sites | 123 | | Files carrying at least one | 22 | @@ -45,11 +45,11 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| | `ui/` | 174 | 163 | 5 | 0 | 6 | -| `data/` | 154 | 72 | 1 | 0 | 81 | +| `data/` | 156 | 74 | 1 | 0 | 81 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **440** | **311** | **6** | **0** | **123** | +| **total** | **442** | **313** | **6** | **0** | **123** | ## File-level triage — site counts @@ -98,7 +98,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `driver/turso.zod.ts` | 2 | | `external-catalog.zod.ts` | 4 | | `field-value.zod.ts` | 3 | -| `field.zod.ts` | 11 | +| `field.zod.ts` | 13 | | `filter.zod.ts` | 11 | | `hook-body.zod.ts` | 2 | | `hook.zod.ts` | 7 | @@ -108,7 +108,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 1 | | `validation.zod.ts` | 6 | -| **total** | **154** | +| **total** | **156** | ### `automation/` — sites @@ -177,7 +177,7 @@ over it is here. ### `data/` — open -**81 strip of 154**, in 12 file(s). +**81 strip of 156**, in 12 file(s). | File | Strip | Sites | |---|---|---| @@ -188,12 +188,12 @@ over it is here. | `driver.zod.ts` | 9 | 9 | | `external-catalog.zod.ts` | 4 | 4 | | `field-value.zod.ts` | 2 | 3 | -| `field.zod.ts` | 2 | 11 | +| `field.zod.ts` | 2 | 13 | | `filter.zod.ts` | 10 | 11 | | `hook.zod.ts` | 5 | 7 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **81** | **154** | +| **total** | **81** | **156** | | Bucket | Sites | |---|---| diff --git a/examples/app-showcase/src/data/objects/invoice.object.ts b/examples/app-showcase/src/data/objects/invoice.object.ts index 480be2eac8..79a8c24214 100644 --- a/examples/app-showcase/src/data/objects/invoice.object.ts +++ b/examples/app-showcase/src/data/objects/invoice.object.ts @@ -204,20 +204,23 @@ export const InvoiceLine = ObjectSchema.create({ * `defaultHidden`), so all seven stay default-visible and `receipt`'s * visibility stops depending on a tie-break it happens to be losing. * - * Bare `{ field }` entries on purpose: `hydrateColumns` fills label, type, - * options, lookup target, `readonlyWhen`/`requiredWhen` and the computed - * `expression` from the schema, so labels stay translatable and the - * columns cannot drift from the field definitions above. `position` is - * absent because it is the grid's drag-reorder sort field, never a cell. + * Identity-only `{ name }` entries on purpose: `hydrateColumns` fills + * label, type, options, lookup target, `readonlyWhen`/`requiredWhen` and + * the computed `expression` from the schema, so labels stay translatable + * and the columns cannot drift from the field definitions above. + * (`name` is the grid's column identity since objectui#3951 — the + * retired `field` spelling this block originally used is now refused at + * parse, #9227.) `position` is absent because it is the grid's + * drag-reorder sort field, never a cell. */ inlineColumns: [ - { field: 'product' }, - { field: 'description' }, - { field: 'service_start' }, - { field: 'quantity' }, - { field: 'unit_price' }, - { field: 'receipt' }, - { field: 'amount' }, + { name: 'product' }, + { name: 'description' }, + { name: 'service_start' }, + { name: 'quantity' }, + { name: 'unit_price' }, + { name: 'receipt' }, + { name: 'amount' }, ], }), // Catalog lookup. Picking a product auto-fills `description` + `unit_price` diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 8e7ca74648..bb97e30f79 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -338,6 +338,8 @@ "IndexSchema (const)", "InjectedColumnProvenance (type)", "InjectedSystemColumnPlan (interface)", + "InlineGridColumn (type)", + "InlineGridColumnSchema (const)", "InstantValue (type)", "InstantValueSchema (const)", "JSONValidation (type)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index 9ef84147f4..2273628ed6 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -463,6 +463,26 @@ "data/Index:partial [RETIRED]", "data/Index:type [RETIRED]", "data/Index:unique", + "data/InlineGridColumn:accept", + "data/InlineGridColumn:autofill", + "data/InlineGridColumn:computed", + "data/InlineGridColumn:defaultHidden", + "data/InlineGridColumn:displayField", + "data/InlineGridColumn:expr", + "data/InlineGridColumn:idField", + "data/InlineGridColumn:label", + "data/InlineGridColumn:multiple", + "data/InlineGridColumn:name", + "data/InlineGridColumn:options", + "data/InlineGridColumn:prefix", + "data/InlineGridColumn:readonlyWhen", + "data/InlineGridColumn:reference", + "data/InlineGridColumn:required", + "data/InlineGridColumn:requiredWhen", + "data/InlineGridColumn:scale", + "data/InlineGridColumn:step", + "data/InlineGridColumn:type", + "data/InlineGridColumn:width", "data/JSONValidation:_lock", "data/JSONValidation:_lockDocsUrl", "data/JSONValidation:_lockReason", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index c83c2a02c4..6832489cb7 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -338,6 +338,8 @@ "IndexSchema": "src/data/object.zod.ts#IndexSchema (const)", "InjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#InjectedColumnProvenance (type)", "InjectedSystemColumnPlan": "src/data/injected-system-columns.ts#InjectedSystemColumnPlan (interface)", + "InlineGridColumn": "src/data/field.zod.ts#InlineGridColumn (type)", + "InlineGridColumnSchema": "src/data/field.zod.ts#InlineGridColumnSchema (const)", "InstantValue": "src/data/field-value.zod.ts#InstantValue (type)", "InstantValueSchema": "src/data/field-value.zod.ts#InstantValueSchema (const)", "JSONValidation": "src/data/validation.zod.ts#JSONValidation (type)", diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 5b878d8a3c..3b7bd08323 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -96,6 +96,7 @@ "data/HookEvent", "data/ImportFieldMapping", "data/Index", + "data/InlineGridColumn", "data/InstantValue", "data/JSONValidation", "data/Lifecycle", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 162597a2c0..2fc020ea4b 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -265,8 +265,92 @@ }, "inlineColumns": { "status": "live", - "evidence": "objectui: packages/plugin-form/src/deriveMasterDetail.ts + app-shell/MetadataProvider.tsx", - "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui." + "verifiedAt": "2026-08-17", + "evidenceScope": "cross-repo", + "evidence": "objectui: packages/app-shell/src/providers/MetadataProvider.tsx (attachInlineSubforms passes d.inlineColumns as the subform's columns) → plugin-form/MasterDetailForm.tsx (hydrateColumns) → fields/GridField.tsx (the name-keyed GridColumn renderer, objectui#3951)", + "note": "LIVE via objectui renderer. #9227 closed the element shape (strict, name-keyed InlineGridColumnSchema): the child keys below are exactly the GridColumn keys the widget has a live read for, measured against objectui main 2026-08-17. NOTE the .objectui-sha pin (665661ab) predates objectui#3951 — the PINNED console still reads the retired field spelling; the drilled evidence below cites objectui main, which is the contract this schema mirrors.", + "children": { + "name": { + "status": "live", + "note": "objectui GridField.tsx — the column's row key: every cell read/write is row[c.name]; hydrateColumns resolves the child field def by col.name (deriveMasterDetail.ts). #3951 aligned this from the retired `field` spelling." + }, + "label": { + "status": "live", + "note": "objectui GridField.tsx header cells render c.label; hydrateColumns fills it from the child field's label when absent." + }, + "type": { + "status": "live", + "note": "objectui GridField.tsx — selects the cell control (text/number/currency/date/datetime/time/select/lookup/file) and drives type-based min widths (minWidthFor/widthStyle); hydrateColumns derives it from the child field type when absent." + }, + "width": { + "status": "live", + "note": "objectui GridField.tsx minWidthFor/widthStyle — a declared width pins the column; otherwise type-based role sizing." + }, + "required": { + "status": "live", + "note": "objectui GridField.tsx — header asterisk and inline-invalid flag on empty cells; curation keeps required columns always visible (deriveMasterDetail curateColumns)." + }, + "options": { + "status": "live", + "note": "objectui GridField.tsx select cells — SelectItem per {label,value}; display formatting resolves the stored value to its label." + }, + "prefix": { + "status": "live", + "note": "objectui GridField.tsx currency cells — rendered symbol (c.prefix || '¥') in both display and edit." + }, + "step": { + "status": "live", + "note": "objectui GridField.tsx numeric cells — input step (c.step ?? 'any')." + }, + "reference": { + "status": "live", + "note": "objectui GridField.tsx lookup cells — passed to LookupField as the referenced object." + }, + "displayField": { + "status": "live", + "note": "objectui GridField.tsx lookup cells — LookupField display_field." + }, + "idField": { + "status": "live", + "note": "objectui GridField.tsx lookup cells — LookupField id_field." + }, + "multiple": { + "status": "live", + "note": "objectui GridField.tsx — multi-record lookup cells and multi-file upload cells." + }, + "accept": { + "status": "live", + "note": "objectui GridField.tsx file cells — joined into the file input's accept attribute." + }, + "defaultHidden": { + "status": "live", + "note": "objectui GridField.tsx — column chooser visibility; deriveColumns sets it past the curation budget, and an authored value is honoured the same way." + }, + "computed": { + "status": "live", + "note": "objectui GridField.tsx — read-only computed cells (recomputeRow filters c.computed && c.expr); computed columns are exempt from required." + }, + "expr": { + "status": "live", + "note": "objectui GridField.tsx evalArith — the BARE arithmetic string the grid's own safe evaluator tokenizes (never a CEL envelope; the schema refuses the envelope form, #9227)." + }, + "scale": { + "status": "live", + "note": "objectui GridField.tsx — rounding for computed results (c.scale ?? currency default 2)." + }, + "autofill": { + "status": "live", + "note": "objectui GridField.tsx lookup pick — copies same-named fields from the picked record into sibling columns unless col.autofill === false." + }, + "readonlyWhen": { + "status": "live", + "note": "objectui GridField.tsx — per-row CEL predicate via resolveFieldRuleState (record + parent scope); cell renders read-only when TRUE." + }, + "requiredWhen": { + "status": "live", + "note": "objectui GridField.tsx — per-row CEL predicate via resolveFieldRuleState; cell flagged required when TRUE." + } + } }, "inlineAmountField": { "status": "live", @@ -289,8 +373,10 @@ }, "relatedListColumns": { "status": "live", - "evidence": "objectui: packages/app-shell/src/utils/deriveRelatedLists.ts + views/RecordDetailView.tsx", - "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui." + "verifiedAt": "2026-08-17", + "evidenceScope": "cross-repo", + "evidence": "objectui: packages/app-shell/src/utils/deriveRelatedLists.ts (columns override on the derived descriptor) → views/RecordDetailView.tsx → plugin-detail/RelatedList.tsx (normalizeColumn hydrates STRING entries: header from the child field label, type-aware cell via makeCell)", + "note": "LIVE via objectui renderer. #9227 narrowed the elements to child FIELD-NAME STRINGS — the only authored form in-repo (three showcase objects) and the only form the renderer hydrates fully; the page-block sibling record:related_list.columns (ui/component.zod.ts) is the same strings-only shape. Measured 2026-08-17 against objectui main: object entries span two vocabularies (RelatedList resolves identity via columnIdentity, canonical `field`; the data-table accessor resolves `accessorKey || name` only), so a spec-canonical {field} object renders BLANK cells — refused at parse instead." }, "relatedListFilter": { "status": "planned", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 68fe83056c..189273d227 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 50 | 0 | 0 | 1 | 51 | -| `field` | 69 | 0 | 0 | 2 | 71 | +| `field` | 88 | 0 | 0 | 2 | 90 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **777** | **6** | **55** | **9** | **847** | +| **total** | **796** | **6** | **55** | **9** | **866** | diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 38d50d4016..37434cc6a4 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -583,6 +583,83 @@ function fieldKeyGuidanceAsStrictOptions() { return { aliases, guidance }; } +/** + * What `z.array(z.any())` cost on the two explicit column lists below (#9227): + * every column object validated — right keys, wrong keys, misspelled keys, + * empty objects — so a mis-keyed column published clean and surfaced only in + * the browser, as a grid with the right row COUNT and every cell blank + * (objectui#3951 measured exactly this failure one seam over, in the + * renderer). A lenient producer schema is where AI-generated metadata errors + * hide; these shapes close it at publish time. + */ +const INLINE_GRID_COLUMN_HISTORY = + 'Until #9227 closed this shape these parsed as `z.any()` — a mis-keyed column published ' + + 'clean and rendered as blank cells, with nothing naming the wrong key.'; + +/** + * One explicit column of the inline master-detail grid (`inlineColumns`). + * + * STRICT mirror of the objectui inline-grid renderer's `GridColumn` + * (`packages/fields/src/widgets/GridField.tsx`, hydration in + * `packages/plugin-form/src/deriveMasterDetail.ts`) — the objectui#3951 + * `name`-keyed contract. Admits exactly the keys that renderer has a live + * read for (measured against objectui main, 2026-08-17); an unknown key is a + * named rejection at publish time, and the retired `field` spelling is + * refused with a prescription naming `name`. + * + * The minimal — and recommended — authored entry is identity-only + * (`{ name: 'quantity' }`): when a column declares no `type`, objectui's + * `hydrateColumns` fills `label`, `type`, `options`, the lookup target and + * conditional rules, and the computed expression from the child object's own + * field definitions, so the columns cannot drift from the fields they show. + * Declaring a `type` opts that column out of hydration entirely — supply the + * extras it needs (options / reference / …) yourself. + */ +export const InlineGridColumnSchema = lazySchema(() => strictObject({ + surface: 'this inline grid column', + history: INLINE_GRID_COLUMN_HISTORY, + aliases: { + // The retired grid spelling: objectui#3951 aligned the widget to `name` + // (the FORM-layer identity key), with deliberately no tolerant alias in + // the renderer — the refusal here is the producer-side half of that. + field: 'name', fieldName: 'name', key: 'name', + title: 'label', header: 'label', + size: 'width', + // The field-level formula key; a grid column's computed cell reads the + // bare arithmetic `expr` (paired with `computed`), never a CEL envelope. + expression: 'expr', + hidden: 'defaultHidden', + }, +}, { + name: z.string().min(1).describe('Child field this column shows — the key the grid reads and writes on each row object (objectui GridColumn.name, #3951). The retired `field` spelling is refused.'), + label: z.string().optional().describe("Column header; defaults to the child field's label via hydration."), + type: z.enum(['text', 'number', 'currency', 'date', 'datetime', 'time', 'select', 'lookup', 'file']).optional().describe("Cell control, derived from the child field's type when omitted. Declaring it opts the column out of schema hydration — supply the extras (options / reference / …) yourself."), + width: z.number().positive().optional().describe('Fixed column width in px; omitted columns use type-based role sizing (text flexes, numeric/date/select stay fixed).'), + required: z.boolean().optional().describe('Cell is flagged inline-invalid while empty. Computed columns are never required.'), + options: z.array(strictObject({ + surface: 'this inline grid column option', + history: INLINE_GRID_COLUMN_HISTORY, + aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value' }, + }, { + label: z.string().describe('Option label shown in the select cell.'), + value: z.string().min(1).describe("Stored option value; must match the child select field's option values."), + })).optional().describe("Select-cell options for `type: 'select'`; derived from the child field's options when the column declares no `type`."), + prefix: z.string().optional().describe("Currency symbol rendered inside a `currency` cell (default '¥')."), + step: z.number().positive().optional().describe('Input step for numeric cells.'), + reference: z.string().optional().describe("Referenced object for `type: 'lookup'` cells; derived from the child lookup field when the column declares no `type`."), + displayField: z.string().optional().describe('Label field shown for a picked lookup record.'), + idField: z.string().optional().describe('Id field stored for a picked lookup record.'), + multiple: z.boolean().optional().describe('Multi-value column: multi-record lookup, or multi-file upload cell.'), + accept: z.array(z.string()).optional().describe("Accepted MIME types / extensions for a `file` cell's picker (e.g. ['image/*', '.pdf']); omit to accept anything."), + defaultHidden: z.boolean().optional().describe("Collapsed into the grid's column chooser by default (not dropped); required columns are never default-hidden."), + computed: z.boolean().optional().describe('Read-only computed column, recomputed live from sibling cells via `expr` and written back into the row.'), + expr: z.string().min(1).optional().describe("Arithmetic expression for a computed column — a BARE string over `+ - * / %`, parentheses, numeric literals and field refs (`record.qty` or `qty`), evaluated by the grid's own safe evaluator. Deliberately NOT a CEL Expression envelope; `{ dialect, source }` is refused here."), + scale: z.number().int().nonnegative().optional().describe('Decimal places to round a computed numeric/currency result to.'), + autofill: z.boolean().optional().describe("For `lookup` columns: picking a record copies its same-named fields into sibling columns (a product's unit_price/description). On by default; set false to disable."), + readonlyWhen: ExpressionInputSchema.optional().describe("Predicate (CEL) — the cell is read-only when TRUE, evaluated per row against the row as `record` plus the header as `parent` (e.g. P`parent.status == 'paid'`)."), + requiredWhen: ExpressionInputSchema.optional().describe('Predicate (CEL) — the cell is required when TRUE. Same `record` + `parent` scope as `readonlyWhen`.'), +})); + export const FieldSchema = lazySchema(() => strictObject({ surface: 'this field', history: FIELD_HISTORY, @@ -831,8 +908,14 @@ export const FieldSchema = lazySchema(() => strictObject({ inlineEdit: z.union([z.boolean(), z.enum(['grid', 'form'])]).optional().describe('Edit these child records inline within the parent\'s form (atomic master-detail). true = auto-pick grid/form by child shape; \'grid\' = editable line-item grid; \'form\' = list + per-row full form.'), /** Optional section title for the inline grid (defaults to the child object label). */ inlineTitle: z.string().optional().describe('Title for the inline master-detail grid'), - /** Optional explicit grid columns for the inline editor (derived from the child object when omitted). */ - inlineColumns: z.array(z.any()).optional().describe('Explicit columns for the inline grid (derived from the child object when omitted)'), + /** + * Optional explicit grid columns for the inline editor (derived from the + * child object when omitted). Strict `name`-keyed element schema + * ({@link InlineGridColumnSchema}, #9227) mirroring the objectui grid + * renderer's measured reads — an unknown or retired key (`field`) is a + * named rejection at publish time, never a blank cell at render time. + */ + inlineColumns: z.array(InlineGridColumnSchema).optional().describe("Explicit columns for the inline grid (derived from the child object when omitted). Each entry is a strict, name-keyed column ({ name, label?, type?, … } — objectui GridColumn, #3951); identity-only entries ({ name }) hydrate everything else from the child object's fields. Unknown keys and the retired `field` spelling are refused at parse."), /** Optional numeric child field summed for the inline grid running total. */ inlineAmountField: z.string().optional().describe('Numeric child field summed for the inline grid total'), @@ -866,8 +949,25 @@ export const FieldSchema = lazySchema(() => strictObject({ relatedList: z.union([z.boolean(), z.literal('primary')]).optional().describe('Show this child collection as a related list on the parent\'s detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); \'primary\' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085).'), /** Optional section title for the detail-page related list (defaults to the child object label). */ relatedListTitle: z.string().optional().describe('Title for the detail-page related list'), - /** Optional explicit columns for the detail-page related list (derived from the child object when omitted). */ - relatedListColumns: z.array(z.any()).optional().describe('Explicit columns for the detail-page related list (derived from the child object when omitted)'), + /** + * Optional explicit columns for the detail-page related list (derived from + * the child object when omitted). Child FIELD-NAME STRINGS only (#9227) — + * the read-side list derives labels, cell types and formatting from the + * child object's field definitions, so the columns cannot drift from them. + * Deliberately narrower than `inlineColumns`: the related list is not an + * editable grid, and per-column display overrides are not part of its + * measured renderer contract (objectui RelatedList hydrates string entries + * fully; the page-block sibling `record:related_list.columns` is the same + * strings-only shape). Column OBJECTS are refused with a prescription. + */ + relatedListColumns: z.array(z.string({ + error: (issue) => issue.code === 'invalid_type' + ? "Related-list columns are child FIELD-NAME strings (e.g. ['name', 'status', 'total']). " + + 'Column objects are not authorable here: the related list derives labels, cell types and ' + + "formatting from the child object's field definitions, so declare display changes on the " + + 'child fields themselves.' + : undefined, + }).min(1)).optional().describe("Explicit columns for the detail-page related list, as child field names (e.g. ['name', 'status']); derived from the child object (highlightFields → field walk) when omitted. Strings only — labels, cell types and formatting always derive from the child object's field definitions; column objects are refused at parse."), /** * Declarative default FILTER for the detail-page related list (#8704). The * auto-derived related list for this relationship queries the child object @@ -1407,6 +1507,10 @@ export type FieldParsed = z.infer; export type SelectOption = z.input; /** Post-parse shape of {@link SelectOption} — defaults applied, transforms run (ADR-0122). */ export type SelectOptionParsed = z.infer; +/** One authored `inlineColumns` entry (#9227) — the strict, name-keyed inline grid column. */ +export type InlineGridColumn = z.input; +/** Post-parse shape of {@link InlineGridColumn} — bare-string CEL predicates normalized to Expression envelopes (ADR-0122). */ +export type InlineGridColumnParsed = z.infer; export type LocationCoordinates = z.input; export type Address = z.input; export type CurrencyConfig = z.input; diff --git a/packages/spec/src/data/inline-related-columns.test.ts b/packages/spec/src/data/inline-related-columns.test.ts new file mode 100644 index 0000000000..6557445a86 --- /dev/null +++ b/packages/spec/src/data/inline-related-columns.test.ts @@ -0,0 +1,251 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #9227 — `inlineColumns` / `relatedListColumns` strictness. + * + * Both keys were `z.array(z.any())`: every column object validated — right + * keys, wrong keys, misspelled keys, empty objects — so a mis-keyed column + * published clean and the only place it was ever noticed was the browser, as + * a grid with the right row COUNT and every cell blank (objectui#3951 + * measured exactly this failure in the renderer; `z.any()` kept it reachable + * from the authoring side after the renderer was fixed). + * + * What is pinned here: + * + * 1. THE DOOR — both column lists are reached THROUGH `FieldSchema` on a + * real relationship field, not as standalone schemas: strictness does + * not recurse, so a closed parent proves nothing about a nested element. + * 2. `inlineColumns`: the strict `name`-keyed element mirrors the objectui + * grid renderer's measured reads (GridField.tsx `GridColumn` + + * deriveMasterDetail.ts hydration). The retired `field` spelling is + * refused WITH the prescription naming `name` (the maintainer-ruled + * #4001 refusal shape); unknown keys get the named-surface refusal. + * 3. `expr` stays a BARE arithmetic string — the grid's own evaluator + * (`evalArith`) tokenizes a string; a CEL envelope authored there would + * parse clean and render every computed cell '—'. The refusal is the + * producer-side guard for that renderer fact. + * 4. `relatedListColumns`: child field-name STRINGS only, matching every + * in-repo usage and the strings-only page-block sibling + * (`record:related_list.columns`, ui/component.zod.ts). A column OBJECT + * is refused with the derivation prescription. + * 5. The showcase invoice fixture form — identity-only `{ name }` entries — + * parses, so the one authored in-repo usage stays green in the spelling + * the renderer actually reads. + */ + +import { describe, it, expect } from 'vitest'; + +import { FieldSchema, InlineGridColumnSchema } from './field.zod'; + +/** Reject `value` through `schema` and return its issues as a searchable string. */ +function reject( + schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, + value: unknown, +): string { + const r = schema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return JSON.stringify((r.error as { issues?: unknown })?.issues ?? r.error ?? []); +} + +/** Parse `value` and fail loudly (with the issues) if it does not succeed. */ +function accept( + schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown; data?: unknown } }, + value: unknown, +): unknown { + const r = schema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify((r.error as { issues?: unknown })?.issues ?? '')}`).toBe(true); + return r.data; +} + +/** A minimal master_detail relationship field — the real carrier of both keys. */ +const MD_FIELD = { + name: 'invoice', + label: 'Invoice', + type: 'master_detail', + reference: 'showcase_invoice', +} as const; + +const acceptField = (patch: Record): unknown => + accept(FieldSchema, { ...MD_FIELD, ...patch }); +const rejectField = (patch: Record): string => + reject(FieldSchema, { ...MD_FIELD, ...patch }); + +// =========================================================================== +// 1. inlineColumns — the strict name-keyed grid column +// =========================================================================== +describe('#9227 inlineColumns — strict name-keyed element', () => { + it('accepts identity-only entries (the showcase invoice fixture form)', () => { + const parsed = acceptField({ + inlineEdit: 'grid', + inlineTitle: 'Line Items', + inlineColumns: [ + { name: 'product' }, + { name: 'description' }, + { name: 'service_start' }, + { name: 'quantity' }, + { name: 'unit_price' }, + { name: 'receipt' }, + { name: 'amount' }, + ], + }) as { inlineColumns: Array<{ name: string }> }; + expect(parsed.inlineColumns).toHaveLength(7); + expect(parsed.inlineColumns[0]).toEqual({ name: 'product' }); + }); + + it('accepts every measured renderer-read key on one column, round-tripping the values', () => { + const parsed = acceptField({ + inlineEdit: 'grid', + inlineColumns: [{ + name: 'unit_price', + label: 'Unit Price', + type: 'currency', + width: 140, + required: true, + prefix: '$', + step: 0.01, + defaultHidden: false, + scale: 2, + }, { + name: 'product', + type: 'lookup', + reference: 'showcase_product', + displayField: 'name', + idField: 'id', + multiple: false, + autofill: true, + readonlyWhen: "parent.status == 'paid'", + }, { + name: 'status', + type: 'select', + options: [{ label: 'Draft', value: 'draft' }, { label: 'Sent', value: 'sent' }], + requiredWhen: { dialect: 'cel', source: 'record.quantity > 100' }, + }, { + name: 'receipt', + type: 'file', + accept: ['image/*', '.pdf'], + }, { + name: 'amount', + computed: true, + expr: 'quantity * unit_price', + scale: 2, + }], + }) as { inlineColumns: Array> }; + expect(parsed.inlineColumns[0]).toMatchObject({ name: 'unit_price', type: 'currency', width: 140, prefix: '$' }); + // Bare-string CEL predicates normalize to the Expression envelope, exactly + // as the field-level readonlyWhen does (same ExpressionInputSchema). + expect(parsed.inlineColumns[1].readonlyWhen).toEqual({ dialect: 'cel', source: "parent.status == 'paid'" }); + expect(parsed.inlineColumns[2].requiredWhen).toMatchObject({ dialect: 'cel', source: 'record.quantity > 100' }); + // The computed column's expr survives as the BARE string the grid evaluator reads. + expect(parsed.inlineColumns[4].expr).toBe('quantity * unit_price'); + }); + + it('refuses the retired `field` spelling with the prescription naming `name`', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ field: 'amount', label: 'Amount', type: 'currency' }], + }); + expect(issues).toContain('this inline grid column'); + expect(issues).toContain('`field`'); + expect(issues).toContain('`field` → `name`'); + // The refusal also fires because `name` is missing — the element is not + // merely stripped-and-accepted. + expect(issues).toContain('name'); + }); + + it('refuses an unknown key with the named surface and a distance suggestion', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', lable: 'Amount' }], + }); + expect(issues).toContain('Unrecognized key(s) on this inline grid column'); + expect(issues).toContain('`lable` → `label`'); + }); + + it('refuses a nonsense key outright (the issue repro: publish-time, not blank cells)', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', zzz: 1 }], + }); + expect(issues).toContain('Unrecognized key(s) on this inline grid column'); + expect(issues).toContain('`zzz`'); + }); + + it('refuses a bare string where a column object belongs', () => { + rejectField({ inlineEdit: 'grid', inlineColumns: ['amount'] }); + }); + + it('refuses a `type` outside the grid renderer cell-control vocabulary', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'notes', type: 'textarea' }], + }); + // The refusal lands at the column's own `type` path and names the whole + // cell-control vocabulary (the enum echoes the options, not the input). + expect(issues).toContain('"path":["inlineColumns",0,"type"]'); + expect(issues).toContain('"select"'); + expect(issues).toContain('"lookup"'); + }); + + it('refuses a CEL envelope on `expr` — the grid evaluator reads a bare arithmetic string', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', computed: true, expr: { dialect: 'cel', source: 'quantity * unit_price' } }], + }); + expect(issues).toContain('expr'); + }); + + it('refuses the field-level `expression` spelling on a column, prescribing `expr`', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'amount', expression: 'quantity * unit_price' }], + }); + expect(issues).toContain('`expression` → `expr`'); + }); + + it('refuses a mis-keyed select option inside `options`', () => { + const issues = rejectField({ + inlineEdit: 'grid', + inlineColumns: [{ name: 'status', type: 'select', options: [{ text: 'Draft', value: 'draft' }] }], + }); + expect(issues).toContain('this inline grid column option'); + expect(issues).toContain('`text` → `label`'); + }); + + it('the element schema is exported and closed on its own', () => { + accept(InlineGridColumnSchema, { name: 'quantity' }); + reject(InlineGridColumnSchema, { name: 'quantity', field: 'quantity' }); + }); +}); + +// =========================================================================== +// 2. relatedListColumns — child field-name strings only +// =========================================================================== +describe('#9227 relatedListColumns — strings only', () => { + it('accepts the in-repo showcase spellings', () => { + const parsed = acceptField({ + relatedListColumns: ['name', 'status', 'total', 'issued_on'], + }) as { relatedListColumns: string[] }; + expect(parsed.relatedListColumns).toEqual(['name', 'status', 'total', 'issued_on']); + acceptField({ relatedListColumns: ['title', 'status', 'priority', 'assignee', 'due_date'] }); + acceptField({ relatedListColumns: ['name', 'status', 'health', 'budget', 'end_date'] }); + }); + + it('refuses a column OBJECT with the derivation prescription', () => { + const issues = rejectField({ + relatedListColumns: [{ name: 'amount', label: 'Amount' }], + }); + expect(issues).toContain('FIELD-NAME strings'); + expect(issues).toContain("child object's field definitions"); + }); + + it('refuses the object form in the retired grid spelling too — same named refusal', () => { + const issues = rejectField({ + relatedListColumns: [{ field: 'amount' }], + }); + expect(issues).toContain('FIELD-NAME strings'); + }); + + it('refuses an empty-string column', () => { + rejectField({ relatedListColumns: [''] }); + }); +}); From 9120ca2b093597ea84c21fe728ac829dc1f13e28 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 14:44:29 +0000 Subject: [PATCH 2/3] feat(spec): register ADR-0087 conversion field-column-lists-canonicalized (#9227) D2 conversion respelling inline-grid column entries field->name and folding related-list column objects to their identity string; wired into the step-18 chain; InlineGridColumnParsed alias per ADR-0122; regenerated api-surface and export-origins. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- .changeset/inline-related-columns-strict.md | 10 +- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + packages/spec/src/conversions/registry.ts | 150 ++++++++++++++++++++ packages/spec/src/migrations/registry.ts | 13 +- 5 files changed, 171 insertions(+), 4 deletions(-) diff --git a/.changeset/inline-related-columns-strict.md b/.changeset/inline-related-columns-strict.md index 64dca93db6..3e9fd8fa7e 100644 --- a/.changeset/inline-related-columns-strict.md +++ b/.changeset/inline-related-columns-strict.md @@ -33,6 +33,10 @@ every cell blank (the objectui#3951 failure, reachable from the authoring side). object is refused with a prescription pointing at the child fields. Migration: respell `{ field: 'x' }` inline-grid columns as `{ name: 'x' }`; -replace related-list column objects with the child field name string. The one -in-repo usage (`examples/app-showcase` invoice line items) is migrated in this -change. +replace related-list column objects with the child field name string — or run +`os migrate meta`, which rewrites both mechanically (registered conversion +`field-column-lists-canonicalized`, protocol 18). The one in-repo usage +(`examples/app-showcase` invoice line items) is migrated in this change. + + + diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index bb97e30f79..c123d5c45d 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -339,6 +339,7 @@ "InjectedColumnProvenance (type)", "InjectedSystemColumnPlan (interface)", "InlineGridColumn (type)", + "InlineGridColumnParsed (type)", "InlineGridColumnSchema (const)", "InstantValue (type)", "InstantValueSchema (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 6832489cb7..1b528d8874 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -339,6 +339,7 @@ "InjectedColumnProvenance": "src/data/injected-system-column-provenance.ts#InjectedColumnProvenance (type)", "InjectedSystemColumnPlan": "src/data/injected-system-columns.ts#InjectedSystemColumnPlan (interface)", "InlineGridColumn": "src/data/field.zod.ts#InlineGridColumn (type)", + "InlineGridColumnParsed": "src/data/field.zod.ts#InlineGridColumnParsed (type)", "InlineGridColumnSchema": "src/data/field.zod.ts#InlineGridColumnSchema (const)", "InstantValue": "src/data/field-value.zod.ts#InstantValue (type)", "InstantValueSchema": "src/data/field-value.zod.ts#InstantValueSchema (const)", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index be44181b79..1f38c2fe1b 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -7025,6 +7025,155 @@ const elementFilterRemoved: MetadataConversion = { }, }; +/** + * `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half + * of the #9227 strict-element narrowing (protocol 18). + * + * Both keys were `z.array(z.any())`: every column object validated, so a + * mis-keyed column published clean and surfaced only in the browser as blank + * cells (the objectui#3951 failure, reachable from the authoring side). The + * schemas are now closed: + * + * - `inlineColumns` entries are the strict, `name`-keyed + * `InlineGridColumnSchema` — `name` is the grid's column identity since + * objectui#3951 retired the `field` spelling from the widget, with + * deliberately no tolerant alias in the renderer. The conversion respells + * `{ field: 'x' }` (the pre-#3951 authored form — the shape the showcase + * invoice carried) as `{ name: 'x' }`, preserving every other key. An + * entry already carrying `name` is left alone — rewriting a live key on + * the strength of a stale one would guess; the parse refuses the mixed + * shape loudly instead. + * - `relatedListColumns` entries are child FIELD-NAME STRINGS. The + * conversion folds an object entry to its identity string + * (`field` → `name` → `fieldName`, objectui's `columnIdentity` order) and + * drops the decoration keys: measured on objectui main, an object entry's + * display keys span two vocabularies (`columnIdentity` is + * canonical-`field`, the data-table accessor reads `accessorKey || name`), + * so no object spelling renders reliably — the derived-from-schema string + * is the one form that always has. An object with no resolvable identity + * is left for the parse to refuse; a conversion must not invent data. + */ +const fieldColumnListsCanonicalized: MetadataConversion = { + id: 'field-column-lists-canonicalized', + toMajor: 18, + retiredFromLoadPath: true, + surface: 'field.inlineColumns[].field / field.relatedListColumns[] object entries', + summary: + "inline-grid column entries respelled 'field' → 'name' (objectui#3951's name-keyed GridColumn) " + + 'and related-list column objects folded to their child field-name string (#9227 — both lists ' + + 'were z.any(); a mis-keyed column published clean and rendered blank cells)', + apply(stack, emit) { + const convertFieldDef = (def: Dict, path: string): Dict => { + let next: Dict = def; + const inline = def.inlineColumns; + if (Array.isArray(inline)) { + let changed = false; + const cols = inline.map((entry, i) => { + if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry; + const renamed = renameKey(entry, 'field', 'name'); + if (!renamed) return entry; + emit({ from: 'field', to: 'name', path: `${path}.inlineColumns[${i}].name` }); + changed = true; + return renamed; + }); + if (changed) next = { ...next, inlineColumns: cols }; + } + const related = def.relatedListColumns; + if (Array.isArray(related)) { + let changed = false; + const cols = related.map((entry, i) => { + if (!isDict(entry)) return entry; + const identity = [entry.field, entry.name, entry.fieldName].find( + (v): v is string => typeof v === 'string' && v.length > 0, + ); + if (!identity) return entry; // nothing to fold to — the parse refuses it loudly + emit({ from: 'object entry', to: identity, path: `${path}.relatedListColumns[${i}]` }); + changed = true; + return identity; + }); + if (changed) next = { ...next, relatedListColumns: cols }; + } + return next; + }; + const convertCollection = (input: Dict, collection: string): Dict => + mapCollection(input, collection, (owner, path) => { + const fields = owner.fields; + if (!isDict(fields)) return owner; + let changed = false; + const nextFields: Dict = {}; + for (const [name, def] of Object.entries(fields)) { + if (!isDict(def)) { + nextFields[name] = def; + continue; + } + const converted = convertFieldDef(def, `${path}.fields.${name}`); + nextFields[name] = converted; + if (converted !== def) changed = true; + } + return changed ? { ...owner, fields: nextFields } : owner; + }); + return convertCollection(convertCollection(stack, 'objects'), 'objectExtensions'); + }, + fixture: { + before: { + objects: [{ + name: 'showcase_invoice_line', + label: 'Invoice Line', + fields: { + invoice: { + type: 'master_detail', + reference: 'showcase_invoice', + inlineEdit: 'grid', + inlineColumns: [ + // The pre-#3951 authored form (the showcase invoice's real shape). + { field: 'product' }, + { field: 'quantity', label: 'Qty' }, + // Already name-keyed — untouched. + { name: 'unit_price' }, + ], + }, + project: { + type: 'lookup', + reference: 'showcase_project', + relatedListColumns: [ + // Object entries fold to their identity string, whichever legacy + // spelling carries it; string entries are untouched. + { field: 'status', label: 'Status' }, + { name: 'amount' }, + 'issued_on', + ], + }, + }, + }], + }, + after: { + objects: [{ + name: 'showcase_invoice_line', + label: 'Invoice Line', + fields: { + invoice: { + type: 'master_detail', + reference: 'showcase_invoice', + inlineEdit: 'grid', + inlineColumns: [ + { name: 'product' }, + { name: 'quantity', label: 'Qty' }, + { name: 'unit_price' }, + ], + }, + project: { + type: 'lookup', + reference: 'showcase_project', + relatedListColumns: ['status', 'amount', 'issued_on'], + }, + }, + }], + }, + // 2 inline respells + 2 related-list folds. + expectedNotices: 4, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -7102,6 +7251,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly Date: Mon, 17 Aug 2026 23:14:00 +0000 Subject: [PATCH 3/3] chore(spec): regenerate os-regen artifacts after merging origin/main Discharges the deferral recorded by the merge commit. Regenerated via the generators only (spec build -> gen:schema/gen:openapi, then check:generated --fix): api-surface/, export-origins/, authorable-surface/, json-schema.manifest/, content/docs/references/**, the strictness-ledger counts and the liveness state-counts. All 13 generated artifacts current. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP --- content/docs/references/index.mdx | 10 +++++----- ...26-07-unknown-key-strictness-ledger.counts.md | 16 ++++++++-------- packages/spec/liveness/state-counts.md | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index d8751c9f21..b586a40cdc 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1585 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1586 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,8 +32,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 160 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **198** | **1585** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 161 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **198** | **1586** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 160 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 161 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -385,7 +385,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`responsive.zod.ts`](/docs/references/ui/responsive) | `BreakpointColumnMap`, `BreakpointName`, `BreakpointOrderMap`, `ResponsiveConfig`, `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | | [`theme.zod.ts`](/docs/references/ui/theme) | `BorderRadius`, `ColorPalette`, `Shadow`, `Theme`, `ThemeMode`, `Typography` | -| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | +| [`view.zod.ts`](/docs/references/ui/view) | `AddRecordConfig`, `AppearanceConfig`, `CalendarConfig`, `ColumnPrefix`, `ColumnSummary`, `ColumnSummaryConfig`, `FormButtonConfig`, `FormField`, `FormFieldPublicPicker`, `FormSection`, `FormView`, `GalleryConfig`, `GanttConfig`, `GanttQuickFilter`, `GroupingConfig`, `GroupingField`, `HttpMethodSubset`, `HttpRequest`, `KanbanConfig`, `ListChartConfig`, `ListColumn`, `ListMapConfig`, `ListView`, `NavigationConfig`, `NavigationMode`, `ObjectListView`, `ObjectUserFilters`, `PaginationConfig`, `RowColorConfig`, `RowHeight`, `SelectionConfig`, `TimelineConfig`, `TreeConfig`, `UserActionsConfig`, `UserFilterField`, `UserFilters`, `View`, `ViewData`, `ViewFilterRule`, `ViewItem`, `ViewItemName`, `ViewItemWire`, `ViewKind`, `ViewScope`, `ViewSharing`, `ViewTab`, `VisualizationType` | --- diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index b99cc67c97..ea3246ce13 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -21,7 +21,7 @@ regenerate. | Measure | Value | |---|---| | Triaged directories | 5 | -| Object sites in them | 442 | +| Object sites in them | 443 | | Still-open (strip) sites | 123 | | Files carrying at least one | 22 | @@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `ui/` | 174 | 163 | 5 | 0 | 6 | +| `ui/` | 175 | 164 | 5 | 0 | 6 | | `data/` | 156 | 74 | 1 | 0 | 81 | | `automation/` | 65 | 42 | 0 | 0 | 23 | | `security/` | 20 | 7 | 0 | 0 | 13 | | `studio/` | 27 | 27 | 0 | 0 | 0 | -| **total** | **442** | **313** | **6** | **0** | **123** | +| **total** | **443** | **314** | **6** | **0** | **123** | ## File-level triage — site counts @@ -75,9 +75,9 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `responsive.zod.ts` | 4 | | `sharing.zod.ts` | 1 | | `theme.zod.ts` | 6 | -| `view.zod.ts` | 55 | +| `view.zod.ts` | 56 | | `widget.zod.ts` | 1 | -| **total** | **174** | +| **total** | **175** | ### `data/` — sites @@ -156,15 +156,15 @@ over it is here. ### `ui/` — open -**6 strip of 174**, in 4 file(s). +**6 strip of 175**, in 4 file(s). | File | Strip | Sites | |---|---|---| | `action-params.zod.ts` | 1 | 1 | | `app.zod.ts` | 1 | 18 | -| `view.zod.ts` | 3 | 55 | +| `view.zod.ts` | 3 | 56 | | `widget.zod.ts` | 1 | 1 | -| **total** | **6** | **174** | +| **total** | **6** | **175** | | Bucket | Sites | |---|---| diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 189273d227..0d42b4bd0f 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 | 1 | 0 | 17 | | `dataset` | 27 | 0 | 0 | 0 | 27 | | `page` | 23 | 0 | 0 | 1 | 24 | -| `view` | 77 | 0 | 9 | 0 | 86 | +| `view` | 77 | 0 | 9 | 1 | 87 | | `report` | 21 | 0 | 0 | 0 | 21 | | `dashboard` | 34 | 0 | 7 | 0 | 41 | | `webhook` | 19 | 0 | 0 | 0 | 19 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **796** | **6** | **55** | **9** | **866** | +| **total** | **796** | **6** | **55** | **10** | **867** |