diff --git a/.changeset/21589-detail-entry-sort-field-retired.md b/.changeset/21589-detail-entry-sort-field-retired.md new file mode 100644 index 00000000000..aa70334828f --- /dev/null +++ b/.changeset/21589-detail-entry-sort-field-retired.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: an `object-master-detail-form` detail entry's `sortField` is retired — the console derives the line-position field from the child object and reads no authored value (#21589) + +Clause-②: no (narrowing) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + +`ComponentPropsMap['object-master-detail-form'].details[].sortField` named the child field the line grid stamps with each line's position on drag-reorder. The console stopped reading it: the field it stamps is derived from the child object, and the pinned console crossed that change while the spec still declared the key. So an authored `sortField` went through `os validate` clean and was dropped, and a drag-reorder stamped the derived field, or none (ADR-0049 enforce-or-remove). + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `details: [{ childObject: 'crm_invoice_line', sortField: 'line_no' }]` | delete `sortField`. The grid stamps the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. | +| `sortField` naming a field outside that list | give the child object one of those fields; the line order is kept there. | +| an entry that names `relationshipField` and at least one column and gives every column a `type` | unchanged: the renderer keeps that entry exactly as authored, loads no child schema for it, and stamps no line position, before and after the upgrade alike. | + +**The one-line fix: delete `sortField` from every `object-master-detail-form` detail entry.** `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + +**What an author now sees.** Writing the key fails `tsc` (its input type is the retired-key mark), and `os validate`, `os build` and `os lint` report it as a `component-props-invalid` warning carrying the prescription at `properties.details.N.sortField`. A page that carries it still saves and loads: a page component's `properties` is not parsed on the metadata save or load path. + +### The retirement kit + +- **Tombstone.** `sortField` is a `retiredKey()` on the strict detail entry. Its prescription prints the derived field names from their one declaration, a module reached by relative import only (`data/inline-grid-sort-fields.ts`), which the derived inline-grid columns read too. +- **D2 conversion `object-master-detail-form-detail-sort-field-removed`** (step 18, retired from the load path): a lossless delete of `sortField` from every `properties.details[]` entry of an `object-master-detail-form`, scoped by component type and by position. Stored `sys_metadata` pages and built artifacts replay it, one notice per entry. +- **D3 entry `object-master-detail-form-detail-sort-field-retired`** carries the judgment the delete cannot make: whether the child object declares the field the line order is kept in. +- **`RETIRED_KEYS_BY_MAJOR[18]`** registers the nested key `ui/ObjectMasterDetailFormProps:details.sortField`. +- **`record:line_items`' answer to `sortField`** no longer sends the author to the detail entry: no block takes an authored `sortField` any more. +- **No deprecation window**: the writer census is zero. + +**Measured producers: none.** On origin/main 9a4182a752, no `object-master-detail-form` detail entry in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes `sortField`, against the sibling detail-entry key `addLabel` on the showcase project workspace's entry as the control, through the same instrument. At the objectui pin `89cad75d5570` the only detail entries that write it are probes asserting that nothing reads it. Deployed metadata NOT MEASURED. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index ae8afe2f215..734d6365349 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1084,7 +1084,7 @@ Sort field and direction pair | **formFields** | `string[]` | optional | Child field names for the per-row expand form. When omitted they are derived from the child object's fields — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is derived, and the per-row form is offered only when `inlineMode` is 'form', where it draws the child object's full field list | | **inlineMode** | `Enum<'grid' \| 'form'>` | optional | Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + per-row full form. When omitted it is resolved from the relationship field's `inlineEdit`, else from the child object's shape — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is resolved, the collection renders as a grid, and the per-row form is offered only when `formFields` lists more fields than `columns` | | **amountField** | `string` | optional | Numeric child column summed for the running total and the `totalField` rollup. When omitted it is picked from the grid's number and currency columns: a computed one, else one named `amount`, `total`, `subtotal`, `line_total`, `line_amount` or `net_amount`, else the last currency column, else the last numeric one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored, so nothing is picked. With no `amountField` authored or picked, the sums read a child column named `amount`, and the grid shows a running total only when `totalField` is set | -| **sortField** | `string` | optional | Child field holding the line sort position, stamped on drag-reorder. When omitted it is the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`, if it has one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored: nothing is derived, the grid stamps no line position, and a drag-reorder is not saved | +| **sortField** | `never` | optional | [REMOVED] `object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17 (ADR-0087 D2) — the console reads no authored value: the field the line grid stamps with each line's position on drag-reorder is derived from the child object, so an authored `sortField` was accepted and dropped. Delete the key. For a drag-reorder to be saved, give the child object a field named `position` / `sort_order` / `sequence` / `line_no` / `line_number` / `sort`: the renderer stamps the child's first field with one of those names — except on an entry that names `relationshipField` and at least one column and gives every column a `type`, which the renderer keeps exactly as authored and stamps no line position on. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **totalField** | `string` | optional | Parent field to receive the rolled-up sum | | **title** | `string` | optional | Section title | | **minRows** | `number` | optional | Minimum number of rows | diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index a4f7cf10fd0..11e5c85aef6 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -91,6 +91,32 @@ describe('validateComponentProps — undeclared keys', () => { } }); + // #21589 — an `object-master-detail-form` detail entry's `sortField` is a + // retiredKey tombstone, one array level down. The same door, the same + // warning: the finding names the entry's key, and the page is never refused. + it('reports a retired detail-entry `sortField` as a warning carrying the prescription, at the entry\'s key', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'object-master-detail-form', + properties: { + objectName: 'invoice', + details: [ + { title: 'Payments', childObject: 'invoice_payment' }, + { title: 'Lines', childObject: 'invoice_line', sortField: 'line_no' }, + ], + }, + }, + ]), + ); + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.severity).toBe('warning'); + expect(f.rule).toBe(COMPONENT_PROPS_INVALID); + expect(f.path).toBe('pages[0].regions[0].components[0].properties.details.1.sortField'); + expect(f.message).toContain('`object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17'); + }); + it('walks components nested inside `properties` (tabs items → children)', () => { const findings = validateComponentProps( stackWith([ diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2d89e2846e3..9de2376ad3b 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -12473,6 +12473,208 @@ const dashboardWidgetChartConfigStructureRemoved: MetadataConversion = { }, }; +/** + * `object-master-detail-form`'s detail entry `sortField` leaves the contract + * (protocol 18, #21589 — ADR-0049 enforce-or-remove; the spec half of + * objectui#11070 round 9, the direction recorded on #21220's landing and + * mirrored on objectui#11396 ③: tombstone plus ADR-0087). + * + * **A pure lossless delete.** The console stopped reading the authored + * override at objectui `0a3e5409f`, and the `.objectui-sha` pin + * (`89cad75d5570`) is past it: `MasterDetailDetailConfig` has no `sortField` + * member (`plugin-form/src/MasterDetailForm.tsx:83`), and the field the line + * grid stamps with each line's position is the one `deriveDetail` derives from + * the child object (`deriveMasterDetail.ts:540`), handed to the grid as + * `sort_field` (`:874`). An authored value changed nothing on that console, so + * deleting it preserves observed behaviour exactly; the line order is kept by + * the child object's own field, which the entry's tombstone names. The + * tombstone refuses the key for a live author (advisory, via the props lint: + * `PageComponentSchema.properties` is an open bag). + * + * ⚠️ Scoped by component `type` and by POSITION — `properties.details[]` of an + * `object-master-detail-form` — never by key name: `sortField` is an ordinary + * name for an open-namespace component's own prop, and the fixture's + * non-carrier control is such a component authoring the same shape. A + * `details` entry that is not an object rides through untouched (the props + * gate reports it; it is not this entry's to fix). + * + * Zero authored occurrences in this repo's corpora at the retirement — no + * detail entry under `examples/`, `apps/`, `packages/`, `skills/` or + * `content/docs/` writes the key (control: the sibling detail-entry key + * `addLabel`, authored on the showcase project workspace's entry, same + * instrument, origin/main 9a4182a752) — so this entry exists for stored + * `sys_metadata` rows and for authors outside the repo, which the census could + * not measure. + */ +const objectMasterDetailFormDetailSortFieldRemoved: MetadataConversion = { + id: 'object-master-detail-form-detail-sort-field-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.6.0', + surface: 'page.component.object-master-detail-form.details[].sortField', + summary: + "object-master-detail-form detail entry prop 'sortField' removed (the console reads no authored " + + 'value: the line grid stamps the field it derives from the child object, so the key was ' + + "accepted and dropped; delete the key — the child object's own position field keeps the line order)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + if (component.type !== 'object-master-detail-form') return component; + const properties = component.properties; + if (!isDict(properties) || !Array.isArray(properties.details)) return component; + const details = properties.details as unknown[]; + let changed = false; + const nextDetails = details.map((entry, i) => { + if (!isDict(entry) || !('sortField' in entry)) return entry; + changed = true; + return stripKeys(entry, ['sortField'], emit, `${path}.properties.details[${i}]`); + }); + if (!changed) return component; + return { ...component, properties: { ...properties, details: nextDetails } }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'invoice_entry', + regions: [ + { + name: 'main', + components: [ + // The carrier: a detail entry authoring the retired override, + // beside an entry WITHOUT it, which rides through untouched. + { + type: 'object-master-detail-form', + id: 'm1', + properties: { + objectName: 'crm_invoice', + details: [ + { childObject: 'crm_invoice_line', title: 'Lines', sortField: 'line_no' }, + { childObject: 'crm_invoice_payment', title: 'Payments' }, + ], + }, + }, + // ⚠️ The same shape on a component that is NOT an + // `object-master-detail-form` — its own prop, not this entry's + // key. Untouched: the strip is scoped by component type. + { + type: 'acme:line_editor', + id: 'x1', + properties: { details: [{ childObject: 'crm_invoice_line', sortField: 'position' }] }, + }, + // A block whose entries carry no `sortField`, and one with a + // non-object entry: both ride through untouched, by reference. + { + type: 'object-master-detail-form', + id: 'm2', + properties: { objectName: 'crm_order', details: [{ childObject: 'crm_order_line' }, 'crm_order_note'] }, + }, + // The nested position (#6775's lesson): a block inside a + // card's `children` is still a component. + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-master-detail-form', + id: 'm3', + properties: { + objectName: 'crm_quote', + details: [{ childObject: 'crm_quote_line', sortField: 'position' }], + }, + }, + ], + }, + }, + ], + }, + ], + }, + // The named-slot shape (#6776): the block authored into a slotted page. + { + name: 'invoice_entry_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-master-detail-form', + id: 'm4', + properties: { + objectName: 'crm_invoice', + details: [{ childObject: 'crm_invoice_line', sortField: 'sequence' }], + }, + }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'invoice_entry', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-master-detail-form', + id: 'm1', + properties: { + objectName: 'crm_invoice', + details: [ + { childObject: 'crm_invoice_line', title: 'Lines' }, + { childObject: 'crm_invoice_payment', title: 'Payments' }, + ], + }, + }, + { + type: 'acme:line_editor', + id: 'x1', + properties: { details: [{ childObject: 'crm_invoice_line', sortField: 'position' }] }, + }, + { + type: 'object-master-detail-form', + id: 'm2', + properties: { objectName: 'crm_order', details: [{ childObject: 'crm_order_line' }, 'crm_order_note'] }, + }, + { + type: 'page:card', + id: 'c1', + properties: { + children: [ + { + type: 'object-master-detail-form', + id: 'm3', + properties: { objectName: 'crm_quote', details: [{ childObject: 'crm_quote_line' }] }, + }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'invoice_entry_detail', + kind: 'slotted', + regions: [], + slots: { + details: { + type: 'object-master-detail-form', + id: 'm4', + properties: { objectName: 'crm_invoice', details: [{ childObject: 'crm_invoice_line' }] }, + }, + }, + }, + ], + }, + // Three notices: the region-level entry, the nested one and the slotted + // one. The open-namespace sibling and the entries without the key emit none. + expectedNotices: 3, + }, +}; + /** * `object.tenancy.organizationField` leaves the authorable surface (protocol * 18, #19054 — ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18, @@ -14598,6 +14800,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: objectGridDefaultSortRemoved, order: 14 }, { conversion: objectGridResizableColumnsRemoved, order: 57 }, { conversion: objectKanbanQuickAddRemoved, order: 15 }, + { conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 60 }, { conversion: objectTenancyOrganizationFieldRemoved, order: 35 }, { conversion: pageAssignedProfilesRemoved, order: 31 }, { conversion: pageComponentFilterRecordToRuleArray, order: 36 }, diff --git a/packages/spec/src/data/inline-grid-columns.ts b/packages/spec/src/data/inline-grid-columns.ts index 4c0884e40d5..3eedbed06ce 100644 --- a/packages/spec/src/data/inline-grid-columns.ts +++ b/packages/spec/src/data/inline-grid-columns.ts @@ -86,6 +86,11 @@ * no expand control. */ +// The sort-position names live in their own module, reached by relative +// import only: the retired `object-master-detail-form` detail entry +// `sortField`'s prescription prints the same list (`ui/component.zod.ts`). +import { INLINE_GRID_SORT_FIELDS } from './inline-grid-sort-fields'; + /** Default-visible column budget of a derived inline grid; the rest are `defaultHidden`. */ export const DEFAULT_MAX_INLINE_GRID_COLUMNS = 6; @@ -107,11 +112,6 @@ const INLINE_GRID_SYSTEM_FIELDS: ReadonlySet = new Set([ 'organization_id', 'tenant_id', 'space', 'owner', ]); -/** Field names that hold a line's sort position: the grid stamps them on drag-reorder. */ -const INLINE_GRID_SORT_FIELDS: ReadonlySet = new Set([ - 'position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort', -]); - /** Field types a line-item cell cannot edit. File-family types are absent: they render an upload cell. */ const INLINE_GRID_NON_EDITABLE_TYPES: ReadonlySet = new Set([ 'formula', 'summary', 'rollup', 'autonumber', 'auto_number', diff --git a/packages/spec/src/data/inline-grid-sort-fields.ts b/packages/spec/src/data/inline-grid-sort-fields.ts new file mode 100644 index 00000000000..730899d294c --- /dev/null +++ b/packages/spec/src/data/inline-grid-sort-fields.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The child field names that hold a line's sort position — ONE declaration, + * read by every place the spec states which field an inline grid stamps. + * + * The renderer derives the field it stamps with each line's position on + * drag-reorder from the child object: the first of these names the child + * declares (objectui `packages/plugin-form/src/deriveMasterDetail.ts`, + * `SORT_FIELD_NAMES`, the same six names in the same order at the + * `.objectui-sha` pin `89cad75d5570`). Two readers here: + * + * - `inline-grid-columns.ts`, whose derived grid columns and per-row form + * fields skip these names, as the renderer's do; + * - `ui/component.zod.ts`, whose prescriptions for the retired + * `object-master-detail-form` detail entry `sortField` name them: the + * authored override is gone, so naming the child's field IS the remedy. + * + * Why a module of its own: both files must print the SAME list, never two + * copies of it, and exported from `inline-grid-columns.ts` it would ride the + * `data` barrel into the published API — a contract for what is the + * renderer's derivation rule, mirrored here so the spec's text agrees with it. + * This module is reached by relative import only, like + * `ui/action-target-aliases.ts`. + */ + +/** Field names that hold a line's sort position: the grid stamps them on drag-reorder. */ +export const INLINE_GRID_SORT_FIELDS: ReadonlySet = new Set([ + 'position', 'sort_order', 'sequence', 'line_no', 'line_number', 'sort', +]); + +/** + * {@link INLINE_GRID_SORT_FIELDS} as a prescription prints it, in order: + * `` `position` / `sort_order` / … / `sort` ``. A module-level constant of a + * module that imports nothing, so it is initialised before any importer's body + * runs — `OS_EAGER_SCHEMAS=1` included. + */ +export const INLINE_GRID_SORT_FIELD_LIST: string = [...INLINE_GRID_SORT_FIELDS] + .map((name) => `\`${name}\``) + .join(' / '); diff --git a/packages/spec/src/inline-grid-column-carriers.test.ts b/packages/spec/src/inline-grid-column-carriers.test.ts index 4dcfe89ddb9..4c506cad903 100644 --- a/packages/spec/src/inline-grid-column-carriers.test.ts +++ b/packages/spec/src/inline-grid-column-carriers.test.ts @@ -425,7 +425,13 @@ describe('#20901 — the `field` → `name` respelling is a chain step on the fo */ const MASTER_DETAIL_PROPS = ComponentPropsMap['object-master-detail-form']; -/** Every key objectui's `MasterDetailForm` reads off a detail entry (the `.objectui-sha` pin `31971ff1e28f`). */ +/** + * Every key objectui's `MasterDetailForm` reads off a detail entry (the + * `.objectui-sha` pin `31971ff1e28f`, re-read at `89cad75d5570`). `sortField` + * is not one of them: the renderer derives the line-position field from the + * child object, and the key is a tombstone (#21589, + * `ui/master-detail-detail-sort-field-retirement.test.ts`). + */ const FULL_DETAIL_ENTRY = { childObject: 'crm_invoice_line', relationshipField: 'invoice', @@ -433,7 +439,6 @@ const FULL_DETAIL_ENTRY = { formFields: ['quantity', 'amount'], inlineMode: 'grid', amountField: 'amount', - sortField: 'position', totalField: 'total', title: 'Lines', minRows: 1, @@ -543,7 +548,7 @@ describe('#20928 — the master-detail block\'s detail entry is strict, and its expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); expect((result.data as { details: unknown[] }).details).toEqual([entry]); } - expect(Object.keys(FULL_DETAIL_ENTRY)).toHaveLength(12); + expect(Object.keys(FULL_DETAIL_ENTRY)).toHaveLength(11); }); }); diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectMasterDetailFormProps__details.sortField.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectMasterDetailFormProps__details.sortField.ts new file mode 100644 index 00000000000..e90a3e693c3 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ObjectMasterDetailFormProps__details.sortField.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #21589 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec +// half of objectui#11070 round 9 (the direction recorded on #21220's landing +// and mirrored on objectui#11396 ③: tombstone plus ADR-0087). The console +// retired the authored override at objectui `0a3e5409f`, and the +// `.objectui-sha` pin `89cad75d5570` is past it: `MasterDetailDetailConfig` +// has no `sortField` member (`plugin-form/src/MasterDetailForm.tsx:83`), and +// the field the line grid stamps with each line's position is the one +// `deriveDetail` derives from the child object (`deriveMasterDetail.ts:540`), +// handed to the grid as `sort_field` (`:874`). Tombstoned with `retiredKey()` +// in the strict detail entry; a NESTED row (an array member, spelled without +// its `[]`), so it has no `authorable-surface/` line and checks (b2)/(b3) +// resolve it against the emitted schema. Stored and built pages are stripped +// by the D2 conversion `object-master-detail-form-detail-sort-field-removed`, +// a pure lossless delete scoped by component `type` and position; its D3 +// record is `object-master-detail-form-detail-sort-field-retired`. +// +// Registered under 18, not 17: the removal ships on the 17.x line +// (launch-window convention: accept-set narrowings ride minor releases) and +// the prescription lives at the major boundary where `migrate meta` users +// look — the `ui/PageHeaderProps:breadcrumb` precedent. +export const entry = 'ui/ObjectMasterDetailFormProps:details.sortField'; diff --git a/packages/spec/src/migrations/entries/semantic/18.object-master-detail-form-detail-sort-field-retired.ts b/packages/spec/src/migrations/entries/semantic/18.object-master-detail-form-detail-sort-field-retired.ts new file mode 100644 index 00000000000..18c04d94f37 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.object-master-detail-form-detail-sort-field-retired.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21589 (ADR-0049 enforce-or-remove) — the D3 entry of the +// `object-master-detail-form-detail-sort-field-removed` family (one D3 entry +// per retirement family, even when D2 is lossless). Registered key: +// `ui/ObjectMasterDetailFormProps:details.sortField`. The strip changes +// nothing a user sees, because the console already ignored the authored +// value; what it leaves is the one judgment a delete cannot make — whether the +// child object carries the field the line order is kept in. +export const entry: SemanticMigration = { + id: 'object-master-detail-form-detail-sort-field-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: 'page.component.object-master-detail-form.details[].sortField — a detail entry\'s ' + + 'authored line-position field', + replacement: 'Nothing on the entry: delete the key. The line grid stamps each line\'s position into ' + + 'the child object\'s own field, derived from the child object: its first field named ' + + '`position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line ' + + 'order a drag-reorder sets, give the child object one of those fields (under the name the ' + + 'deleted key named, when it is one of them).', + reason: 'The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes ' + + '`sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: ' + + 'the console stopped reading the authored override, and the line grid stamps the field it ' + + 'derives from the child object whatever the entry says. What the conversion cannot decide is ' + + 'where the line order lives. An entry whose key named a field the derivation does not pick — ' + + 'a name outside that list, or a second sort-named field after the first — saves its line ' + + 'order into the derived field instead, or nowhere when the child object has none. An entry ' + + 'that names `relationshipField` and at least one column and gives every column a `type` is ' + + 'kept exactly as authored: no child schema is loaded for it, so no line position is stamped ' + + 'and a drag-reorder is not saved, before and after the upgrade alike.', + acceptanceCriteria: 'No `object-master-detail-form` detail entry carries `sortField`; the props ' + + 'lint reports one with the prescription. For each entry that had set it, the child object ' + + 'declares the field the line order is kept in under one of the derived names, and after a ' + + 'drag-reorder and save the lines reload in the order they were dragged into.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts index 66626b9d9fe..4a600a8a441 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-master-detail-form-details-closed.ts @@ -21,7 +21,7 @@ export const entry: SemanticMigration = { + 'grid columns), including `scale` on a column that declares no `type` and whose `name` is a ' + '`currency` field of the entry\'s `childObject`', replacement: 'each entry is `{ childObject, relationshipField?, columns?, formFields?, ' - + 'inlineMode?, amountField?, sortField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + + 'inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + '— the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the ' + 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — ' + '`{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child ' diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts index ffa6a4c36de..bd14429b760 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-record-line-items-props-closed.ts @@ -26,9 +26,10 @@ export const entry: SemanticMigration = { + '`{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or ' + '`fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block ' + 'draws a column exactly as declared and hydrates nothing from the child object\'s field; ' - + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `sortField`, ' - + '`formFields` and `inlineMode`, which belong to an `object-master-detail-form` detail entry and ' - + 'are not read here, and any other key the shape does not declare.', + + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `formFields` ' + + 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read ' + + 'here, `sortField`, which no block takes (the detail entry derives the line-position field from ' + + 'the child object), and any other key the shape does not declare.', reason: 'The block draws one inline grid of the record\'s child rows, through the same objectui ' + 'grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` ' + 'row: it was the one entry on the string-arm registration ledger, so the component-props gate ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 69dffdc5eb9..59a00370297 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5852,6 +5852,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'is a pure lossless DELETE (the key never had an effect to preserve) scoped by component ' + '`type`. Delete the key; `object-kanban` offers no quick-add control.', }, + { + id: 'object-master-detail-form-detail-sort-field-retired', + order: 70, + text: + 'It also retires an `object-master-detail-form` detail entry\'s `sortField` (#21589, ADR-0049 ' + + 'enforce-or-remove; the spec half of objectui#11070 round 9). The console stopped reading the ' + + 'authored override: the field its line grid stamps with each line\'s position on ' + + 'drag-reorder is derived from the child object — its first field named `position`, ' + + '`sort_order`, `sequence`, `line_no`, `line_number` or `sort` — and the pinned console had ' + + 'crossed that change while the spec still declared the key, so an authored value published ' + + 'green and was dropped. A retiredKey tombstone on the strict detail entry with one D2 ' + + 'conversion that is a pure lossless DELETE scoped by component `type` and by position ' + + '(`properties.details[]`); its D3 entry `object-master-detail-form-detail-sort-field-retired` ' + + 'carries the one judgment left, whether the child object declares the field the line order ' + + 'is kept in.', + }, { id: 'object-tenancy-organization-field-retired', order: 34, @@ -15251,6 +15267,39 @@ const step18: MigrationStep = { + 'the flag, the author has accepted creating records through the object\'s create action: ' + '`object-kanban` offers no quick-add control.', }, + // #21589 (ADR-0049 enforce-or-remove) — the D3 entry of the + // `object-master-detail-form-detail-sort-field-removed` family (one D3 entry + // per retirement family, even when D2 is lossless). Registered key: + // `ui/ObjectMasterDetailFormProps:details.sortField`. The strip changes + // nothing a user sees, because the console already ignored the authored + // value; what it leaves is the one judgment a delete cannot make — whether the + // child object carries the field the line order is kept in. + { + id: 'object-master-detail-form-detail-sort-field-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: 'page.component.object-master-detail-form.details[].sortField — a detail entry\'s ' + + 'authored line-position field', + replacement: 'Nothing on the entry: delete the key. The line grid stamps each line\'s position into ' + + 'the child object\'s own field, derived from the child object: its first field named ' + + '`position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`. To keep the line ' + + 'order a drag-reorder sets, give the child object one of those fields (under the name the ' + + 'deleted key named, when it is one of them).', + reason: 'The D2 conversion `object-master-detail-form-detail-sort-field-removed` deletes ' + + '`sortField` from every `object-master-detail-form` detail entry, and the delete is lossless: ' + + 'the console stopped reading the authored override, and the line grid stamps the field it ' + + 'derives from the child object whatever the entry says. What the conversion cannot decide is ' + + 'where the line order lives. An entry whose key named a field the derivation does not pick — ' + + 'a name outside that list, or a second sort-named field after the first — saves its line ' + + 'order into the derived field instead, or nowhere when the child object has none. An entry ' + + 'that names `relationshipField` and at least one column and gives every column a `type` is ' + + 'kept exactly as authored: no child schema is loaded for it, so no line position is stamped ' + + 'and a drag-reorder is not saved, before and after the upgrade alike.', + acceptanceCriteria: 'No `object-master-detail-form` detail entry carries `sortField`; the props ' + + 'lint reports one with the prescription. For each entry that had set it, the child object ' + + 'declares the field the line order is kept in under one of the derived names, and after a ' + + 'drag-reorder and save the lines reload in the order they were dragged into.', + }, // #19054 (ADR-0049 enforce-or-remove; maintainer ruling 2026-09-18) — the D3 // entry of the `object-tenancy-organization-field-removed` family (ruling B on // #17152: one D3 entry per retirement family, even when D2 is lossless). This @@ -19925,7 +19974,7 @@ const step18: MigrationStep = { + 'grid columns), including `scale` on a column that declares no `type` and whose `name` is a ' + '`currency` field of the entry\'s `childObject`', replacement: 'each entry is `{ childObject, relationshipField?, columns?, formFields?, ' - + 'inlineMode?, amountField?, sortField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + + 'inlineMode?, amountField?, totalField?, title?, minRows?, maxRows?, addLabel? }` ' + '— the keys the renderer reads — with `inlineMode` one of `grid` / `form`. Each column is the ' + 'strict, name-keyed inline grid column a relationship field\'s `inlineColumns` takes — ' + '`{ name, label?, type?, … }`, where `{ name }` alone hydrates the rest from the child ' @@ -20116,9 +20165,10 @@ const step18: MigrationStep = { + '`{ name, label?, type?, options?, … }`. Write `name` where a column said `field` (or ' + '`fieldName`, `key`); declare `label`, `type` and `options` on the column, because this block ' + 'draws a column exactly as declared and hydrates nothing from the child object\'s field; ' - + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `sortField`, ' - + '`formFields` and `inlineMode`, which belong to an `object-master-detail-form` detail entry and ' - + 'are not read here, and any other key the shape does not declare.', + + 'delete `scale` from a column declaring `type: \'currency\'`; delete `addLabel`, `formFields` ' + + 'and `inlineMode`, which belong to an `object-master-detail-form` detail entry and are not read ' + + 'here, `sortField`, which no block takes (the detail entry derives the line-position field from ' + + 'the child object), and any other key the shape does not declare.', reason: 'The block draws one inline grid of the record\'s child rows, through the same objectui ' + 'grid as the other three carriers of the inline grid column, but it had no `ComponentPropsMap` ' + 'row: it was the one entry on the string-arm registration ledger, so the component-props gate ' @@ -24901,6 +24951,27 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // this shape; `ViewTabSchema` stays, reused by the page-only `userFilters.tabs` // preset bar. D2: `view-list-tabs-removed`. 'ui/ObjectListView:tabs', + // #21589 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec + // half of objectui#11070 round 9 (the direction recorded on #21220's landing + // and mirrored on objectui#11396 ③: tombstone plus ADR-0087). The console + // retired the authored override at objectui `0a3e5409f`, and the + // `.objectui-sha` pin `89cad75d5570` is past it: `MasterDetailDetailConfig` + // has no `sortField` member (`plugin-form/src/MasterDetailForm.tsx:83`), and + // the field the line grid stamps with each line's position is the one + // `deriveDetail` derives from the child object (`deriveMasterDetail.ts:540`), + // handed to the grid as `sort_field` (`:874`). Tombstoned with `retiredKey()` + // in the strict detail entry; a NESTED row (an array member, spelled without + // its `[]`), so it has no `authorable-surface/` line and checks (b2)/(b3) + // resolve it against the emitted schema. Stored and built pages are stripped + // by the D2 conversion `object-master-detail-form-detail-sort-field-removed`, + // a pure lossless delete scoped by component `type` and position; its D3 + // record is `object-master-detail-form-detail-sort-field-retired`. + // + // Registered under 18, not 17: the removal ships on the 17.x line + // (launch-window convention: accept-set narrowings ride minor releases) and + // the prescription lives at the major boundary where `migrate meta` users + // look — the `ui/PageHeaderProps:breadcrumb` precedent. + 'ui/ObjectMasterDetailFormProps:details.sortField', // ADR-0090 D2 (no Profile concept) + ADR-0049 enforce-or-remove; maintainer // ruling 2026-09-12, decision batch #121 item 2, verbatim 「同意」. // `Page.assignedProfiles` was an authorable key named for the concept ADR-0090 D2 diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index cca8b735a74..5881437da05 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -81,6 +81,10 @@ import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../share // `subforms[].columns` take, referenced rather than copied: all three carriers // feed one objectui grid. import { InlineGridColumnSchema } from '../data/field.zod'; +// [#21589] The child field names the renderer derives a detail's line-position +// field from — the one list the retired detail-entry `sortField`'s +// prescriptions print (reached by relative import only, never the barrel). +import { INLINE_GRID_SORT_FIELD_LIST } from '../data/inline-grid-sort-fields'; // --------------------------------------------------------------------------- // CLOSED AGAINST UNKNOWN KEYS as of #4001 batch A -- all 31 object sites. @@ -2209,10 +2213,15 @@ export const RecordLineItemsProps = lazySchema(() => strictObject({ // author moving a child collection over from `object-master-detail-form` // carries along. Measured at the pin: the panel hands the grid no // `add_label` / `sort_field` and no `onRowExpand` (`:694-710`, `:806-817`). + // `sortField` has since left the detail entry too (#21589, a tombstone + // there): the line-position field is derived from the child object and + // never authored, so its answer names no block that takes it. addLabel: '`record:line_items` does not read `addLabel`: its grid draws the built-in, localized ' + 'Add button. `addLabel` belongs to an `object-master-detail-form` detail entry.', sortField: '`record:line_items` does not read `sortField`: its grid stamps no line position, so a ' - + 'drag-reorder is not saved. `sortField` belongs to an `object-master-detail-form` detail entry.', + + 'drag-reorder is not saved. No block takes an authored `sortField`: an ' + + '`object-master-detail-form` detail entry derives the line-position field from the child ' + + `object's ${INLINE_GRID_SORT_FIELD_LIST} field.`, formFields: '`record:line_items` draws an editable grid only, with no per-row expand form, so it ' + 'does not read `formFields`. It belongs to an `object-master-detail-form` detail entry.', inlineMode: '`record:line_items` draws an editable grid only, so it does not read `inlineMode`. It ' @@ -5815,10 +5824,12 @@ const MASTER_DETAIL_DETAIL_HISTORY = /** * One `object-master-detail-form` detail collection (#20928) — STRICT, and - * exactly the twelve keys objectui's `MasterDetailForm` reads off an entry + * exactly the eleven keys objectui's `MasterDetailForm` reads off an entry * (`packages/plugin-form/src/MasterDetailForm.tsx`, its - * `MasterDetailDetailConfig` and every `d.` read in the file, read at the - * `.objectui-sha` pin `31971ff1e28f`). A key nobody reads is not declared. + * `MasterDetailDetailConfig` and every `d.` read in the file; read at the + * `.objectui-sha` pin `31971ff1e28f`, re-read at `89cad75d5570` for #21589), + * plus the `sortField` tombstone ({@link MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED}). + * A key nobody reads is not declared. * * `columns` IS {@link InlineGridColumnSchema}, the same object a relationship * field's `inlineColumns` and a form view's `subforms[].columns` take: the @@ -5831,14 +5842,16 @@ const MASTER_DETAIL_DETAIL_HISTORY = * * The keys the entry shares with a form view's `subforms[]` entry take that * entry's types and alias table, so one concept is spelled one way on both - * child-collection surfaces. The three it adds are the renderer's own: - * `formFields` (the per-row expand form), `inlineMode` (the two form factors - * a relationship field's `inlineEdit` names; absence takes the relationship's - * own resolution only on an entry the renderer derives, and its describe - * states both paths) and `sortField` (the line-position field the grid stamps on - * drag-reorder). `title` and `addLabel` are plain strings because the - * renderer draws them as a React child and a button label without resolving a - * locale map. + * child-collection surfaces. The two it adds are the renderer's own: + * `formFields` (the per-row expand form) and `inlineMode` (the two form + * factors a relationship field's `inlineEdit` names; absence takes the + * relationship's own resolution only on an entry the renderer derives, and its + * describe states both paths). The line-position field the grid stamps on + * drag-reorder is NOT a key: the renderer derives it from the child object + * ({@link INLINE_GRID_SORT_FIELD_LIST}), and `sortField`, the authored override + * it used to take, is a tombstone. `title` and `addLabel` are plain strings + * because the renderer draws them as a React child and a button label without + * resolving a locale map. * * A factory called inside {@link ObjectMasterDetailFormPropsSchema}'s own lazy * body, the way a form view's `subforms[]` entry is built inline in its @@ -5846,6 +5859,41 @@ const MASTER_DETAIL_DETAIL_HISTORY = * nothing exports, which the schema-graph walks (`alias-integrity.test.ts`) * never descend into, so its alias table would go unjudged. */ +/** + * REMOVED (#21589 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route; + * the spec half of objectui#11070 round 9, the direction recorded on #21220's + * landing and mirrored on objectui#11396 ③). + * + * An authored override the console no longer reads. At the `.objectui-sha` pin + * `89cad75d5570`, `MasterDetailDetailConfig` has no `sortField` member + * (`plugin-form/src/MasterDetailForm.tsx:83`): the field the line grid stamps + * with each line's position is DERIVED from the child object (`deriveDetail`, + * `deriveMasterDetail.ts:540`), carried on the resolved entry and handed to + * the grid as `sort_field` (`:874`). The pin crossed that change without the + * spec half, so an authored `sortField` published green, and a drag-reorder + * stamped the derived field, or none. + * + * The live mechanism is the child object's own field: its first field named + * one of {@link INLINE_GRID_SORT_FIELD_LIST}, on an entry the renderer + * resolves. An entry that names `relationshipField` and at least one column + * and gives every column a `type` is kept exactly as authored: the renderer + * loads no child schema for it, so it stamps no line position (`:977-979`). + * + * Stored and built pages that carry the key are stripped by the D2 conversion + * `object-master-detail-form-detail-sort-field-removed`, with a notice; its D3 + * record is `object-master-detail-form-detail-sort-field-retired`. + */ +const MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED = + '`object-master-detail-form` property `details[].sortField` was removed in @objectstack/spec 17 ' + + '(ADR-0087 D2) — the console reads no authored value: the field the line grid stamps with each ' + + 'line\'s position on drag-reorder is derived from the child object, so an authored `sortField` was ' + + 'accepted and dropped. Delete the key. For a drag-reorder to be saved, give the child object a ' + + `field named ${INLINE_GRID_SORT_FIELD_LIST}: the renderer stamps the child's first field with one ` + + 'of those names — except on an entry that names `relationshipField` and at least one column and ' + + 'gives every column a `type`, which the renderer keeps exactly as authored and stamps no line ' + + 'position on. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + function masterDetailDetailEntry() { return strictObject({ surface: 'this `object-master-detail-form` detail entry', @@ -5862,7 +5910,9 @@ function masterDetailDetailEntry() { formFields: z.array(z.string()).optional().describe("Child field names for the per-row expand form. When omitted they are derived from the child object's fields — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is derived, and the per-row form is offered only when `inlineMode` is 'form', where it draws the child object's full field list"), inlineMode: z.enum(['grid', 'form']).optional().describe("Inline-edit form factor: 'grid' = editable cells; 'form' = read-only list + per-row full form. When omitted it is resolved from the relationship field's `inlineEdit`, else from the child object's shape — except on an entry that names both `relationshipField` and at least one column, which is kept as authored: nothing is resolved, the collection renders as a grid, and the per-row form is offered only when `formFields` lists more fields than `columns`"), amountField: z.string().optional().describe("Numeric child column summed for the running total and the `totalField` rollup. When omitted it is picked from the grid's number and currency columns: a computed one, else one named `amount`, `total`, `subtotal`, `line_total`, `line_amount` or `net_amount`, else the last currency column, else the last numeric one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored, so nothing is picked. With no `amountField` authored or picked, the sums read a child column named `amount`, and the grid shows a running total only when `totalField` is set"), - sortField: z.string().optional().describe("Child field holding the line sort position, stamped on drag-reorder. When omitted it is the child object's first field named `position`, `sort_order`, `sequence`, `line_no`, `line_number` or `sort`, if it has one — except on an entry that names `relationshipField` and at least one column and gives every column a `type`. The renderer keeps that entry exactly as authored: nothing is derived, the grid stamps no line position, and a drag-reorder is not saved"), + // A tombstone: the line-position field is derived from the child object, + // never authored — see MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED above. + sortField: retiredKey(MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED), totalField: z.string().optional().describe('Parent field to receive the rolled-up sum'), title: z.string().optional().describe('Section title'), minRows: z.number().optional().describe('Minimum number of rows'), diff --git a/packages/spec/src/ui/master-detail-detail-sort-field-retirement.test.ts b/packages/spec/src/ui/master-detail-detail-sort-field-retirement.test.ts new file mode 100644 index 00000000000..2d8e0b881b0 --- /dev/null +++ b/packages/spec/src/ui/master-detail-detail-sort-field-retirement.test.ts @@ -0,0 +1,474 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An `object-master-detail-form` detail entry's `sortField` RETIRED (#21589) — + * ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec half of + * objectui#11070 round 9 (the direction recorded on #21220's landing, mirrored + * on objectui#11396 ③). + * + * Measured before removal, at the `.objectui-sha` pin `89cad75d5570`: + * `MasterDetailDetailConfig` has no `sortField` member + * (`plugin-form/src/MasterDetailForm.tsx:83`); the field the line grid stamps + * with each line's position is DERIVED from the child object + * (`deriveMasterDetail.ts:540`) and handed to the grid as `sort_field` + * (`:874`). The spec still declared the key, so an authored value published + * green and was dropped. The writer census at the retirement is zero. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on the strict detail entry — the refusal + * carries the prescription, which names the derived field set from its + * one declaration (`data/inline-grid-sort-fields.ts`), and the key's + * input type is `never`. `PageComponentSchema.properties` is an open bag, + * so the props schema is reached by the advisory props lint and never by + * the page parse: an existing page is never hard-refused. + * 2. D2 conversion `object-master-detail-form-detail-sort-field-removed` + * (step 18), a strip scoped by component type and by position, retired + * from the load path: a stored or built page replays clean, with a notice. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries the NESTED key + * `ui/ObjectMasterDetailFormProps:details.sortField`, and the family's D3 + * entry is `object-master-detail-form-detail-sort-field-retired`. + * 4. `record:line_items`' answer to the same spelling names no block that + * takes it any more. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` — no HTTP door parses this row + * (the page write door parses `properties` as an open bag). So each refusal is + * pinned by the issue `code`, the `path` naming the key, and the prescription. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { INLINE_GRID_SORT_FIELDS } from '../data/inline-grid-sort-fields'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ComponentPropsMap, ObjectMasterDetailFormPropsSchema, RecordLineItemsProps } from './component.zod'; +import { PageSchema } from './page.zod'; + +const CONVERSION_ID = 'object-master-detail-form-detail-sort-field-removed'; +const D3_ID = 'object-master-detail-form-detail-sort-field-retired'; +const REGISTERED_KEY = 'ui/ObjectMasterDetailFormProps:details.sortField'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself. +const PRESCRIPTION = + /`object-master-detail-form` property `details\[\]\.sortField` was removed in @objectstack\/spec 17 \(ADR-0087 D2\) — the console reads no authored value.*Delete the key\..*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + +/** A detail entry's live keys — the showcase project workspace's entry shape. */ +const ENTRY = { title: 'Lines', childObject: 'crm_invoice_line', addLabel: 'Add line' } as const; + +const props = (details: unknown[]) => ({ objectName: 'crm_invoice', details }); + +/** A stored `page` row whose block's detail entry carries `sortField`. */ +const storedPage = (sortField: string) => ({ + name: 'invoice_entry', + label: 'Invoice Entry', + type: 'app', + regions: [ + { + name: 'main', + components: [{ type: 'object-master-detail-form', properties: props([{ ...ENTRY, sortField }]) }], + }, + ], +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ + conversionId: n.conversionId, + path: n.path, + from: n.from, + to: n.to, +}); + +describe('object-master-detail-form detail-entry sortField retirement — the tombstone', () => { + it('refuses the key on a detail entry, at the key, with the prescription', () => { + for (const value of ['line_no', 'position']) { + const r = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, sortField: value }])); + expect(r.success, `sortField: ${value}`).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['details', 0, 'sortField']); + expect(issue.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`object-master-detail-form` property `details[].sortField` was removed')).toBe(true); + } + }); + + it('the prescription names every derived sort-field name, from the one declaration', () => { + const r = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, sortField: 'line_no' }])); + const message = r.success ? '' : r.error.issues[0]!.message; + expect(INLINE_GRID_SORT_FIELDS.size).toBe(6); + for (const name of INLINE_GRID_SORT_FIELDS) expect(message, name).toContain(`\`${name}\``); + }); + + it('the row the props lint dispatches on is the same schema, so it refuses it too', () => { + // `validateComponentProps` (packages/lint) reads `ComponentPropsMap[type]`; + // a rebinding to some other shape would pass the pins above and still + // accept the key where an author meets it. + expect(ComponentPropsMap['object-master-detail-form']).toBe(ObjectMasterDetailFormPropsSchema); + expect(() => ComponentPropsMap['object-master-detail-form'].parse(props([{ ...ENTRY, sortField: 'sort' }]))) + .toThrow(PRESCRIPTION); + }); + + it('refuses the key by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { + const retired = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, sortField: 'line_no' }])); + expect(retired.success).toBe(false); + expect((retired.error?.issues ?? []).map((i) => i.code)).not.toContain('unrecognized_keys'); + // CONTROL: an undeclared sibling on the same entry comes back as + // `unrecognized_keys`. Without this pair, a shape that had simply DROPPED + // the key would pass the pin above on the strict arm's generic message. + const undeclared = ObjectMasterDetailFormPropsSchema.safeParse(props([{ ...ENTRY, zzzNotAKey: 'line_no' }])); + expect(undeclared.success).toBe(false); + const issue = undeclared.error?.issues.find((i) => i.code === 'unrecognized_keys') as + | { keys?: string[]; path?: unknown[] } + | undefined; + expect(issue?.keys).toEqual(['zzzNotAKey']); + expect(issue?.path).toEqual(['details', 0]); + }); + + it('CONTROL: an entry without the key parses, keeps its live keys, and grows no `sortField`', () => { + const r = ObjectMasterDetailFormPropsSchema.safeParse(props([ENTRY])); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + if (!r.success) return; + const [entry] = (r.data as { details: Record[] }).details; + expect(entry).toEqual(ENTRY); + expect(entry).not.toHaveProperty('sortField'); + }); + + it('the walked shape keeps `sortField` as a key of the detail entry', () => { + type Unwrapped = { unwrap(): { element: { shape: Record } } }; + const details = (ObjectMasterDetailFormPropsSchema.shape as unknown as Record).details; + const entryKeys = Object.keys(details.unwrap().element.shape); + expect(entryKeys).toContain('sortField'); + expect(entryKeys, 'CONTROL: its live neighbour').toContain('amountField'); + // Eleven keys the renderer reads, plus the tombstone. + expect(entryKeys).toHaveLength(12); + }); + + it('fails tsc at the authoring site: the input type of `sortField` is `never`', () => { + const authored: z.input = { + objectName: 'crm_invoice', + details: [ + { + ...ENTRY, + // @ts-expect-error — `sortField` is a retiredKey() tombstone: its input type is `never`. + sortField: 'line_no', + }, + ], + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => ObjectMasterDetailFormPropsSchema.parse(authored)).toThrow(PRESCRIPTION); + }); + + it('never hard-refuses an existing page: the page parse still accepts a block carrying the key', () => { + // `PageComponentSchema.properties` is an open bag — the page door does not + // dispatch on the component type; the tombstone speaks through the props lint. + const r = PageSchema.safeParse(storedPage('line_no')); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + }); + + it('`record:line_items` answers the same spelling naming no block that takes it', () => { + const r = RecordLineItemsProps.safeParse({ + childObject: 'crm_invoice_line', + relationshipField: 'invoice', + columns: [{ name: 'quantity' }], + sortField: 'line_no', + }); + expect(r.success).toBe(false); + const issue = r.error?.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path).toEqual([]); + expect(issue?.message).toContain('No block takes an authored `sortField`'); + // CONTROL: the sibling entry key still names the block that takes it. + const addLabel = RecordLineItemsProps.safeParse({ + childObject: 'crm_invoice_line', + relationshipField: 'invoice', + columns: [{ name: 'quantity' }], + addLabel: 'Add line', + }); + expect(addLabel.error?.issues.find((i) => i.code === 'unrecognized_keys')?.message) + .toContain('`addLabel` belongs to an `object-master-detail-form` detail entry'); + }); +}); + +describe('object-master-detail-form detail-entry sortField retirement — the D2 conversion', () => { + it('a STORED page whose detail entry carries the key loads with it stripped and the notice recorded', () => { + const notices: ConversionNotice[] = []; + const row = storedPage('line_no'); + const rehydrated = applyConversionsToStoredItem('page', row, { + onNotice: (n) => notices.push(n), + }) as ReturnType; + + expect(notices.map(brief)).toEqual([ + { + conversionId: CONVERSION_ID, + path: 'pages[0].regions[0].components[0].properties.details[0].sortField', + from: 'sortField', + to: '(removed)', + }, + ]); + const properties = rehydrated.regions[0]!.components[0]!.properties; + // Every live key on the same entry and block survives byte-for-byte. + expect(properties).toEqual(props([ENTRY])); + // The rehydrated block is exactly what the props row accepts now, and the + // page still parses. + expect(ObjectMasterDetailFormPropsSchema.safeParse(properties).success).toBe(true); + expect(PageSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a BUILT artifact replays the same strip — every position the block can sit in, every entry', () => { + const artifact = { + pages: [ + { + name: 'invoice_entry', + regions: [ + { + name: 'main', + components: [ + { + type: 'object-master-detail-form', + properties: props([{ ...ENTRY, sortField: 'line_no' }, { childObject: 'crm_payment' }, { childObject: 'crm_step', sortField: 'position' }]), + }, + { + type: 'page:card', + properties: { + children: [{ type: 'object-master-detail-form', properties: props([{ ...ENTRY, sortField: 'sequence' }]) }], + }, + }, + ], + }, + ], + }, + { + name: 'invoice_entry_detail', + kind: 'slotted', + regions: [], + slots: { details: { type: 'object-master-detail-form', properties: props([{ ...ENTRY, sortField: 'sort' }]) } }, + }, + ], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID).map((n) => n.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.details[0].sortField', + 'pages[0].regions[0].components[0].properties.details[2].sortField', + 'pages[0].regions[0].components[1].properties.children[0].properties.details[0].sortField', + 'pages[1].slots.details.properties.details[0].sortField', + ]); + expect(JSON.stringify(stack)).not.toContain('"sortField"'); + }); + + it('CONTROL: a block whose entries carry no key is unchanged — no notice, and the row comes back by reference', () => { + const row = { + name: 'invoice_entry', + regions: [{ name: 'main', components: [{ type: 'object-master-detail-form', properties: props([ENTRY]) }] }], + }; + const notices: ConversionNotice[] = []; + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(rehydrated).toBe(row); + }); + + it('is scoped by component TYPE and POSITION: the same key elsewhere is not this entry\'s', () => { + const artifact = { + pages: [ + { + name: 'p', + regions: [ + { + name: 'main', + components: [ + // Another component's own `details[].sortField`. + { type: 'acme:line_editor', properties: { details: [{ sortField: 'position' }] } }, + // The block's TOP-level props: not a detail entry, so not this + // key (the props gate reports it as an unknown key). + { type: 'object-master-detail-form', properties: { objectName: 'crm_invoice', sortField: 'position' } }, + ], + }, + ], + }, + ], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toBe(artifact); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices({ pages: [storedPage('line_no')] }, { includeRetired: true }); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel does not rewrite a live source', () => { + const input = { pages: [storedPage('line_no')] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('object-master-detail-form detail-entry sortField retirement — the ADR-0087 ledger row', () => { + it('declares ONE nested key under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(REGISTERED_KEY); + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => k.startsWith('ui/ObjectMasterDetailFormProps:'))).toEqual([REGISTERED_KEY]); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('page.component.object-master-detail-form.details[].sortField'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.replacement).toContain('delete the key'); + // The D3 text is concatenated into the generated registry as a literal, so + // it cannot import the one declaration; this keeps the two lists equal. + for (const name of INLINE_GRID_SORT_FIELDS) expect(entry!.replacement, name).toContain(`\`${name}\``); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites, and a page component's `properties` +// is an open bag, so `tsc` does not reach a block authored through +// `definePage`/`defineStack` at all. This walk covers every text file under the +// five repo roots `scripts/cross-package-test-inputs.mjs` declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: `sortField` in key +// position with a snake_case field-name value, quoted or bare (TS / JS / JSON, +// and YAML). A guidance or prescription string, a schema declaration and the +// grid's own `sort_field` never take that shape. Prose mentions are spelled in +// inline code in this repo, and inline code is stripped before judging. The +// bound, stated: a value that is not a field-name literal, a shorthand +// property, and `docs/**`, `.claude/**`, `.github/**` and the repo-root files +// are outside what this walk sees. A future schema that declares a `sortField` +// of its own would trip this walk: narrow the matcher to detail entries then, +// never exclude the new file. +describe('tree-scoped absence: nothing inside the declared radius still authors a detail-entry sortField', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /(^|[^\w.$])["']?sortField["']?[ \t]*:[ \t]*(["']?)[a-z_][a-z0-9_]*\2(?=[ \t]*([,}\]#]|$))/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => AUTHORING.exec(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + // The props-lint pin authors the key to assert the advisory warning an + // author meets at `os validate` / `os build` / `os lint`. + 'packages/lint/src/validate-component-props.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement entry on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `component.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention, a guidance string and a declaration (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("details: [{ childObject: 'crm_invoice_line', sortField: 'line_no' }],")).not.toBeNull(); + expect(judge(" {\n sortField: 'position',\n },")).not.toBeNull(); + expect(judge('{ "details": [{ "childObject": "crm_invoice_line", "sortField": "sequence" }] }')).not.toBeNull(); + expect(judge(' details:\n - childObject: crm_invoice_line\n sortField: sort\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefinePage({ regions: [{ components: [{ properties: { details: [{ sortField: 'line_no' }] } }] }] });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge("an authored `sortField: 'line_no'` was accepted and dropped")).toBeNull(); + expect(judge("sortField: '`record:line_items` does not read `sortField`: its grid stamps no line position'")).toBeNull(); + expect(judge('sortField: retiredKey(MASTER_DETAIL_DETAIL_SORT_FIELD_RETIRED),')).toBeNull(); + expect(judge("sortField: z.string().optional().describe('Child field'),")).toBeNull(); + expect(judge('sortField: derived.sortField,')).toBeNull(); + expect(judge("sort_field: entry.sortField,\n sortFields: ['name'],")).toBeNull(); + expect(judge('"ui/ObjectMasterDetailFormProps:details.sortField",')).toBeNull(); + }); + + it('no detail-entry sortField authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a detail-entry sortField authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 7392a247e8c..757a6c1e324 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -47,6 +47,7 @@ "src/ui/action-requires-confirmation-docblock.pin.test.ts", "src/ui/element-text-variant-heading-retirement.test.ts", "src/ui/form-field-public-picker-retirement.test.ts", + "src/ui/master-detail-detail-sort-field-retirement.test.ts", "src/ui/object-grid-resizable-columns-retirement.test.ts", "src/ui/page-header-breadcrumb-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts",