Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/inline-related-columns-strict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
"@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 — 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.

<!-- adr-0087: registered field-column-lists-canonicalized -->

38 changes: 34 additions & 4 deletions content/docs/references/data/field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand All @@ -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. |
Expand All @@ -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 | 161 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
| **Total** | **198** | **1585** | 14 protocol modules |
| **Total** | **198** | **1586** | 14 protocol modules |

---

Expand Down Expand Up @@ -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.

Expand All @@ -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` |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ regenerate.
| Measure | Value |
|---|---|
| Triaged directories | 5 |
| Object sites in them | 441 |
| Object sites in them | 443 |
| Still-open (strip) sites | 123 |
| Files carrying at least one | 22 |

Expand All @@ -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/` | 175 | 164 | 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** | **441** | **312** | **6** | **0** | **123** |
| **total** | **443** | **314** | **6** | **0** | **123** |

## File-level triage — site counts

Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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 |
|---|---|
Expand Down
27 changes: 15 additions & 12 deletions examples/app-showcase/src/data/objects/invoice.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@
"IndexSchema (const)",
"InjectedColumnProvenance (type)",
"InjectedSystemColumnPlan (interface)",
"InlineGridColumn (type)",
"InlineGridColumnParsed (type)",
"InlineGridColumnSchema (const)",
"InstantValue (type)",
"InstantValueSchema (const)",
"JSONValidation (type)",
Expand Down
20 changes: 20 additions & 0 deletions packages/spec/authorable-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@
"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)",
"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)",
"JSONValidation": "src/data/validation.zod.ts#JSONValidation (type)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/json-schema.manifest/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"data/HookEvent",
"data/ImportFieldMapping",
"data/Index",
"data/InlineGridColumn",
"data/InstantValue",
"data/JSONValidation",
"data/Lifecycle",
Expand Down
Loading
Loading