Skip to content
Merged
11 changes: 11 additions & 0 deletions .changeset/20901-form-view-subform-columns-canonicalized.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/spec': patch
---

fix(spec): a stored form view whose subform grid columns use the `field` spelling is respelled to `name` on the way in, as a relationship field's `inlineColumns` already are (#20901)

**`@objectstack/spec`**

- **New ADR-0087 conversion `form-view-subform-columns-canonicalized` (protocol 18, retired from the authoring path).** A form view's `subforms[].columns` accepted any value through 17.5.0 and now takes the inline grid column contract, which refuses `{ field: 'x' }` with the prescription naming `name`. The conversion rewrites that entry as `{ name: 'x' }`, every other key kept, wherever a form view travels as data at rest: a stored `view` row (its `form`, each `formViews` entry, a form view item's `config`, a flattened form overlay), an assembled manifest's `viewItems`, and `os migrate meta --from 17`, which lists the edit. A built artifact whose declared protocol floor is 17.5.0 or lower is converted too, not refused. An entry that already carries `name` is left alone, including one that carries both `field` and `name`: the parse names both keys, and the author picks one. It is the same respelling `field-column-lists-canonicalized` applies to a relationship field's `inlineColumns`, and both entries run one shared rule.
- **Authored sources are unchanged:** `defineStack` and `objectstack validate` do not replay a retired conversion, so a source that writes `field` on a subform column is still refused with the prescription. Write `name`.
- **Two step-18 migration entries now read true.** `inline-grid-column-currency-scale-refused` no longer says a column declaring no `type` keeps its `scale`: over a `currency` field of the child object, `defineStack` refuses it, under `inline-grid-column-identity-only-currency-scale-refused`, which the entry now names. `form-view-subform-columns-closed` names this conversion as the one mechanical edit on its carrier.
6 changes: 6 additions & 0 deletions packages/spec/scripts/conversions-major18-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,13 @@ describe('major 18 in the conversions registry — two retirements, no merge dri
expect(placementFindings(side)).toEqual([]);
}
expect(sortFindings(retireAtListEnd(SOURCE, A, NEXT_ORDER))).not.toEqual([]);
// A's entry follows its neighbour's, so a neighbour placed by the rule is
// found too: its entry is now followed by A's, and A is defined elsewhere.
const neighbour = IDENTS[k - 1]!;
expect(placementFindings(retireAtDefinitionsEnd(SOURCE, A, NEXT_ORDER))).toEqual([
...(PLACED_BEFORE_THE_RULE.has(neighbour)
? []
: [`${neighbour} is defined above ${IDENTS[k]}; define it directly above ${A}'s definition, the entry that follows it`]),
`${A} is defined above nothing (it is the last conversion defined); define it directly above ${IDENTS[k]}'s `
+ 'definition, the entry that follows it',
]);
Expand Down
200 changes: 190 additions & 10 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7623,6 +7623,36 @@ const translationComponentSubmitLabelRemoved: MetadataConversion = {
},
};

/**
* The inline grid column's one mechanical respelling, shared by both of its
* carriers — a relationship field's `inlineColumns`
* ({@link fieldColumnListsCanonicalized}) and a form view's `subforms[].columns`
* ({@link formViewSubformColumnsCanonicalized}) — so the two conversions cannot
* drift on what they rewrite, as the two carriers cannot on what they accept
* (both reference `InlineGridColumnSchema`).
*
* `{ field: 'x' }` → `{ name: 'x' }`, every other key kept. An entry already
* carrying `name` is left alone — rewriting a live key on the strength of a
* stale one would guess; the parse refuses the mixed shape loudly instead.
* Returns the same array when nothing was respelled (copy-on-write).
*/
function respellInlineGridColumns(
columns: readonly unknown[],
path: string,
emit: (detail: ConversionApplication) => void,
): readonly unknown[] {
let changed = false;
const next = columns.map((entry, i) => {
if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry;
const renamed = renameKey(entry, 'field', 'name');
if (!renamed) return entry;
emit({ from: 'field', to: 'name', path: `${path}[${i}].name` });
changed = true;
return renamed;
});
return changed ? next : columns;
}

/**
* `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half
* of the #9227 strict-element narrowing (protocol 18).
Expand Down Expand Up @@ -7667,16 +7697,8 @@ const fieldColumnListsCanonicalized: MetadataConversion = {
let next: Dict = def;
const inline = def.inlineColumns;
if (Array.isArray(inline)) {
let changed = false;
const cols = inline.map((entry, i) => {
if (!isDict(entry) || typeof entry.field !== 'string' || 'name' in entry) return entry;
const renamed = renameKey(entry, 'field', 'name');
if (!renamed) return entry;
emit({ from: 'field', to: 'name', path: `${path}.inlineColumns[${i}].name` });
changed = true;
return renamed;
});
if (changed) next = { ...next, inlineColumns: cols };
const cols = respellInlineGridColumns(inline, `${path}.inlineColumns`, emit);
if (cols !== inline) next = { ...next, inlineColumns: cols };
}
const related = def.relatedListColumns;
if (Array.isArray(related)) {
Expand Down Expand Up @@ -9820,6 +9842,163 @@ const connectorConnectionTimeoutMsRemoved: MetadataConversion = {
},
};

/**
* `subforms[].columns[].field` → `name` on every form view — the mechanical
* half of the #20901 closure of the form-view carrier (protocol 18).
*
* `FormViewSchema.subforms[].columns` was `z.array(z.any())` through 17.5.0 and
* now references `InlineGridColumnSchema`, whose alias table refuses `field`
* with a prescription naming `name`. That is the break a relationship field's
* `inlineColumns` took in #9227, and the respelling is the same one: it runs
* through {@link respellInlineGridColumns}, shared with
* {@link fieldColumnListsCanonicalized}. ADR-0087's pre-GA policy owes a
* lossless break a `retiredFromLoadPath` chain step in the same release.
*
* **Its own entry, not a wider walk in `field-column-lists-canonicalized`,**
* because `retiredAfter` is one fact per entry (ADR-0087, amended 2026-09-30):
* that entry's carriers stopped accepting `field` after 17.0.0, and the census
* pins that published value, while this carrier accepted it through 17.5.0. The
* artifact-ingestion door opens its window per entry by `retiredAfter`, so under
* the older stamp an artifact whose declared floor is 17.5.0 would meet the
* refusal instead of this rewrite on a runtime still labelled 17.5.0.
*
* **Reach: every FORM payload.** {@link mapViewPayloads} reaches `form`,
* `formViews.*`, a form view item's `config` and a flattened form overlay — the
* stored-row seam wraps a `view` row as `{ views: [row] }` in any of those
* spellings — and the assembled-manifest `viewItems` channel
* ({@link ASSEMBLED_VIEW_ITEMS_KEY}) carries the last two. A list payload is
* never judged. `FormViewSchema` is the only schema that declares `subforms`.
*
* `retiredFromLoadPath`: the parse refuses `field` with the prescription, so a
* live author is taught rather than rewritten. The entry exists so stored rows
* and assembled artifacts replay clean, and so `os migrate meta` lists the edit.
* Idempotent by construction: the rewrite leaves no `field` on the entry.
*/
const formViewSubformColumnsCanonicalized: MetadataConversion = {
id: 'form-view-subform-columns-canonicalized',
toMajor: 18,
retiredFromLoadPath: true,
retiredAfter: '17.5.0',
surface: 'view.form.subforms[].columns[].field / view.formViews.<key>.subforms[].columns[].field',
summary:
"form-view subform grid column entries respelled 'field' → 'name', the grid's column identity "
+ '(the carrier accepted any value until it took the inline grid column contract; a '
+ "relationship field's inlineColumns get the same respelling from field-column-lists-canonicalized)",
apply(stack, emit) {
const respellSubforms = (form: Dict, path: string): Dict => {
const subforms = form.subforms;
if (!Array.isArray(subforms)) return form;
let changed = false;
const next = subforms.map((subform, j) => {
if (!isDict(subform) || !Array.isArray(subform.columns)) return subform;
const columns = respellInlineGridColumns(subform.columns, `${path}.subforms[${j}].columns`, emit);
if (columns === subform.columns) return subform;
changed = true;
return { ...subform, columns };
});
return changed ? { ...form, subforms: next } : form;
};
const withViews = mapViewPayloads(stack, (payload, kind, path) =>
kind === 'form' ? respellSubforms(payload, path) : payload);
return mapCollection(withViews, ASSEMBLED_VIEW_ITEMS_KEY, (item, path) => {
if (item.viewKind !== 'form') return item;
if (isDict(item.config)) {
const config = respellSubforms(item.config, `${path}.config`);
return config === item.config ? item : { ...item, config };
}
// A flattened form overlay — the body IS the payload. A present but
// malformed `config` is neither shape and is left for the parse.
return item.config === undefined ? respellSubforms(item, path) : item;
});
},
fixture: {
before: {
views: [
{
object: 'crm_invoice',
// A container: the default form and a named form view.
form: {
type: 'simple',
subforms: [{
childObject: 'crm_invoice_line',
columns: [
// The `field` spelling, every other key kept.
{ field: 'product' },
{ field: 'quantity', label: 'Qty' },
// Already name-keyed — untouched.
{ name: 'unit_price' },
// Both keys — untouched: which column was meant is the
// author's call, and the parse names both keys.
{ field: 'amount', name: 'total' },
],
}],
},
formViews: {
quick: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }] },
},
},
],
viewItems: [
// An assembled form view item record, and a flattened form overlay.
{
name: 'crm_invoice.entry',
object: 'crm_invoice',
viewKind: 'form',
config: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }] },
},
{
name: 'crm_invoice.edit',
object: 'crm_invoice',
viewKind: 'form',
type: 'simple',
subforms: [{ childObject: 'crm_invoice_line', columns: [{ field: 'product' }] }],
},
],
},
after: {
views: [
{
object: 'crm_invoice',
form: {
type: 'simple',
subforms: [{
childObject: 'crm_invoice_line',
columns: [
{ name: 'product' },
{ name: 'quantity', label: 'Qty' },
{ name: 'unit_price' },
{ field: 'amount', name: 'total' },
],
}],
},
formViews: {
quick: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }] },
},
},
],
viewItems: [
{
name: 'crm_invoice.entry',
object: 'crm_invoice',
viewKind: 'form',
config: { type: 'simple', subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }] },
},
{
name: 'crm_invoice.edit',
object: 'crm_invoice',
viewKind: 'form',
type: 'simple',
subforms: [{ childObject: 'crm_invoice_line', columns: [{ name: 'product' }] }],
},
],
},
// One per respelled column: two in the container's `form`, one in its named
// form view, one in the assembled record and one in the assembled overlay.
// The name-keyed and the two-key entries emit none.
expectedNotices: 5,
},
};

/**
* `hook.timeout` → `hook.timeoutMs` (protocol 18, #14478; maintainer ruling
* 2026-09-02, recorded on the card as "ruled B").
Expand Down Expand Up @@ -13074,6 +13253,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
{ conversion: flowDecisionModeInclusiveExplicit, order: 45 },
{ conversion: formLayoutInlineGridToVertical, order: 40 },
{ conversion: formViewOptionDefaultRemoved, order: 17 },
{ conversion: formViewSubformColumnsCanonicalized, order: 51 },
{ conversion: hookTimeoutToTimeoutMs, order: 21 },
{ conversion: jobTimeoutToTimeoutMs, order: 22 },
{ conversion: listViewSortStringClauseToArray, order: 30 },
Expand Down
Loading
Loading