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
13 changes: 13 additions & 0 deletions .changeset/view-conversions-reach-all-three-spellings.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
---
'@objectstack/spec': patch
---

Every view-family conversion now reaches all three persisted `view` spellings.

`ViewMetadataSchema` accepts three body shapes and all three land in `sys_metadata` rows — the `defineView` container (`list`/`listViews`/`form`/`formViews`), the standalone ViewItem record (`{ viewKind, config }`), and the flattened runtime overlay (a raw ListView/FormView config at the top level plus its `object` + `viewKind` binding). Every view-family conversion walked only the container keys, so for the other two spellings the whole chain replayed by `applyConversionsToStoredItem('view', row)` was a no-op: a row written under an older protocol kept its historical shape while the conversion layer reported it canonicalized, and the rehydration parse then refused exactly what had never been rewritten.

A new shared walker (`mapViewPayloads` in `conversions/walk.ts`) discriminates the three spellings using `ViewMetadataSchema`'s own discriminators — `viewKind` plus a `config` object for a record, the container slots for a container, `viewKind` with those slots absent for a flattened overlay — and hands each conversion the list/form payload wherever it lives, labelled with its family. All five view-family conversions adopt it: `view-visibleOn-to-visibleWhen`, `view-inert-keys-removed`, `view-list-passthrough-keys-removed`, `view-export-options-pdf-removed` and `form-view-option-default-removed`.

The family label is load-bearing rather than informational: these conversions are shape-scoped, and two of them strip a key that is inert on one family and live on the other (`aria` is retired on a form and live on a list, `data` the reverse), so a walk that could not tell the two apart would delete live keys.

No authoring surface moves and no accept set changes — this is data-at-rest canonicalization catching up to shapes the schema already ruled on. Container behaviour, including every notice path, is unchanged.
126 changes: 22 additions & 104 deletions packages/spec/src/conversions/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,7 @@ import {
mapFlowNodes,
mapPageComponents,
mapPages,
mapViewPayloads,
renameConfigKey,
renameKey,
} from './walk.js';
Expand DownExpand Up@@ -428,8 +429,9 @@ function renameVisibilityAlias(
*
* The conditional-visibility predicate is unified under the canonical
* `visibleWhen` across all layers. Applies to form sections and (recursively
* nested) form fields in every `views[].form` / `views[].formViews.*`
* container. **Live window**: the protocol-15 loader accepts the deprecated
* nested) form fields in every FORM payload {@link mapViewPayloads} reaches —
* `views[].form` / `views[].formViews.*`, a ViewItem record's `config`, and a
* flattened form overlay's top level (#13031). **Live window**: the protocol-15 loader accepts the deprecated
* key (the zod schemas also normalize it at parse — this entry makes the
* acceptance *declared, loud, and expiring* per ADR-0087 D2, and will
* graduate into the step-16 chain when the alias is removed).
Expand DownExpand Up@@ -482,22 +484,9 @@ const viewVisibleOnToVisibleWhen: MetadataConversion = {
return dict;
};

return mapCollection(stack, 'views', (view, path) => {
let next = view;
const form = mapForm(next.form, `${path}.form`);
if (form !== next.form) next = { ...next, form };
const formViews = next.formViews;
if (formViews && typeof formViews === 'object' && !Array.isArray(formViews)) {
let fvChanged = false;
const nextViews: Record<string, unknown> = {};
for (const [name, fv] of Object.entries(formViews as Record<string, unknown>)) {
const mapped = mapForm(fv, `${path}.formViews.${name}`);
if (mapped !== fv) fvChanged = true;
nextViews[name] = mapped;
}
if (fvChanged) next = { ...next, formViews: nextViews };
}
return next;
return mapViewPayloads(stack, (payload, kind, path) => {
if (kind !== 'form') return payload;
return mapForm(payload, path) as Dict;
});
},
fixture: {
Expand DownExpand Up@@ -2218,34 +2207,12 @@ const viewInertKeysRemoved: MetadataConversion = {
// NOT 'data': the sweep's removal attempt was refuted by the build —
// defineForm writes data.provider='schema' on every metadata form.
const FORM_KEYS = ['defaultSort', 'aria'] as const;
return mapCollection(stack, 'views', (view, path) => {
let touched = false;
const next: Record<string, unknown> = { ...view };
const fix = (keys: readonly string[], sub: Record<string, unknown>, subPath: string) => {
const cleaned = stripKeys(sub, keys, emit, subPath);
if (cleaned !== sub) { touched = true; return cleaned; }
return sub;
};
for (const [slot, keys] of [['list', LIST_KEYS], ['form', FORM_KEYS]] as const) {
const v = next[slot];
if (v && typeof v === 'object' && !Array.isArray(v)) next[slot] = fix(keys, v as Record<string, unknown>, `${path}.${slot}`);
}
for (const [slot, keys] of [['listViews', LIST_KEYS], ['formViews', FORM_KEYS]] as const) {
const named = next[slot];
if (named && typeof named === 'object' && !Array.isArray(named)) {
const rebuilt: Record<string, unknown> = { ...(named as Record<string, unknown>) };
let subTouched = false;
for (const [name, v] of Object.entries(rebuilt)) {
if (v && typeof v === 'object' && !Array.isArray(v)) {
const cleaned = stripKeys(v as Record<string, unknown>, keys, emit, `${path}.${slot}.${name}`);
if (cleaned !== v) { rebuilt[name] = cleaned; subTouched = true; }
}
}
if (subTouched) { next[slot] = rebuilt; touched = true; }
}
}
return touched ? next : view;
});
// The per-family key sets are why {@link mapViewPayloads} labels every
// payload: `aria` is retired on a form and LIVE on a list, `data` the
// reverse, so a walk that could not tell the two apart would delete a live
// key on whichever family it guessed wrong.
return mapViewPayloads(stack, (payload, kind, path) =>
stripKeys(payload, kind === 'list' ? LIST_KEYS : FORM_KEYS, emit, path));
},
fixture: {
before: {
Expand DownExpand Up@@ -2282,28 +2249,8 @@ const viewListPassthroughKeysRemoved: MetadataConversion = {
summary: "view list keys removed (#7176): 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (pass-through-only; ADR-0049 enforce-or-remove)",
apply(stack, emit) {
const LIST_KEYS = ['striped', 'bordered', 'virtualScroll'] as const;
return mapCollection(stack, 'views', (view, path) => {
let touched = false;
const next: Record<string, unknown> = { ...view };
const list = next.list;
if (list && typeof list === 'object' && !Array.isArray(list)) {
const cleaned = stripKeys(list as Record<string, unknown>, LIST_KEYS, emit, `${path}.list`);
if (cleaned !== list) { next.list = cleaned; touched = true; }
}
const named = next.listViews;
if (named && typeof named === 'object' && !Array.isArray(named)) {
const rebuilt: Record<string, unknown> = { ...(named as Record<string, unknown>) };
let subTouched = false;
for (const [name, lv] of Object.entries(rebuilt)) {
if (lv && typeof lv === 'object' && !Array.isArray(lv)) {
const cleaned = stripKeys(lv as Record<string, unknown>, LIST_KEYS, emit, `${path}.listViews.${name}`);
if (cleaned !== lv) { rebuilt[name] = cleaned; subTouched = true; }
}
}
if (subTouched) { next.listViews = rebuilt; touched = true; }
}
return touched ? next : view;
});
return mapViewPayloads(stack, (payload, kind, path) =>
kind === 'list' ? stripKeys(payload, LIST_KEYS, emit, path) : payload);
},
fixture: {
before: {
Expand DownExpand Up@@ -2388,23 +2335,8 @@ const viewExportOptionsPdfRemoved: MetadataConversion = {
}
return slot;
};
return mapCollection(stack, 'views', (view, path) => {
let touched = false;
const next: Record<string, unknown> = { ...view };
const list = stripPdf(next.list, `${path}.list`);
if (list !== next.list) { next.list = list; touched = true; }
const named = next.listViews;
if (named && typeof named === 'object' && !Array.isArray(named)) {
const rebuilt: Record<string, unknown> = { ...(named as Record<string, unknown>) };
let subTouched = false;
for (const [name, lv] of Object.entries(rebuilt)) {
const cleaned = stripPdf(lv, `${path}.listViews.${name}`);
if (cleaned !== lv) { rebuilt[name] = cleaned; subTouched = true; }
}
if (subTouched) { next.listViews = rebuilt; touched = true; }
}
return touched ? next : view;
});
return mapViewPayloads(stack, (payload, kind, path) =>
kind === 'list' ? (stripPdf(payload, path) as Dict) : payload);
},
fixture: {
before: {
Expand DownExpand Up@@ -8210,8 +8142,9 @@ const permissionAllowRestorePurgeRemoved: MetadataConversion = {
* sources, a pure lossless delete (it never had an effect on this surface to
* lose).
*
* Walks the same containers as `view-visibleOn-to-visibleWhen`: `views[].form`
* and `views[].formViews.*`, through `sections[]`/`groups[]` and top-level
* Walks the same payloads as `view-visibleOn-to-visibleWhen` — every FORM
* payload {@link mapViewPayloads} reaches, in all three persisted spellings
* (#13031) — through `sections[]`/`groups[]` and top-level
* `fields[]`, recursing into nested `fields` (composite/repeater/record rows
* carry their own option lists). Only the exact key `default` is stripped —
* the alias spellings `isDefault`/`selected` were never accepted on this
Expand DownExpand Up@@ -8281,23 +8214,8 @@ const formViewOptionDefaultRemoved: MetadataConversion = {
if (fields !== dict.fields) dict = { ...dict, fields };
return dict;
};
return mapCollection(stack, 'views', (view, path) => {
let next = view;
const form = mapForm(next.form, `${path}.form`);
if (form !== next.form) next = { ...next, form };
const formViews = next.formViews;
if (isDict(formViews)) {
let fvChanged = false;
const nextViews: Record<string, unknown> = {};
for (const [name, fv] of Object.entries(formViews)) {
const mapped = mapForm(fv, `${path}.formViews.${name}`);
if (mapped !== fv) fvChanged = true;
nextViews[name] = mapped;
}
if (fvChanged) next = { ...next, formViews: nextViews };
}
return next;
});
return mapViewPayloads(stack, (payload, kind, path) =>
kind === 'form' ? (mapForm(payload, path) as Dict) : payload);
},
fixture: {
before: {
Expand Down
Loading
Loading