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
51 changes: 51 additions & 0 deletions .changeset/field-designer-master-detail-delete-behavior-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@objectstack/spec': patch
---

The field designer no longer offers a `master_detail` a `deleteBehavior` the schema refuses

#9689 made `deleteBehavior: 'set_null'` authored on a `master_detail` field a
named parse-time rejection. The two shipped metadata forms that let a Studio
author pick that value did not move with it, so the designer kept offering
"Set null" for a `master_detail` — the author picked it and learned only at
publish, from a 422, that the choice was never legal. Declared-vs-enforced, one
seam earlier than the parse door.

Both forms failed the same way through **different option sources**, which is
why the repair is not one edit twice:

- `object.form.ts` declared an inline `options` array carrying all three values
behind a single `visibleWhen` that named both `lookup` and `master_detail`.
- `field.form.ts` declared **no** `options` at all. That is not a narrower
offer — it is the renderer's derived source: with no inline list the
metadata-admin form falls through to the JSON Schema `enum`, which is
`['set_null','cascade','restrict']` and additionally advertises
`default: 'set_null'`. A Zod enum has no per-type narrowing to give, so the
derived path re-offers the refused value by construction, and `master_detail`
can only be served by an explicit list.

Both now declare the control **twice, with disjoint `visibleWhen`** — `lookup`
keeps all three outcomes, `master_detail` is offered `cascade` and `restrict`
only. Nothing is taken away from `lookup`, where all three remain legal:
`object.form.ts` keeps its three-value list unchanged, and `field.form.ts`'s
`lookup` branch keeps deriving from the enum exactly as before, so its labels
and their translation are untouched. The two branches are mutually exclusive, so
no author ever sees both.

**Per-option `visibleWhen` was the tighter-looking spelling and was measured and
rejected.** `SelectOptionSchema` does declare it (ADR-0068), so it reads as
existing vocabulary, but it is not reachable from a metadata form in either
direction: the metadata-admin renderer maps `fieldSpec.options` straight to
select items and never consults it, so writing one would ship an ADR-0049
declared-but-unenforced key; and on the runtime surface that *does* honor it,
the per-option evaluator binds `record` and never `data`, so a `data.`-rooted
predicate there is an unbound identifier whose visibility fails **open**,
keeping the option. Either way the author would still have been offered
"Set null" — a fix that silently does nothing. Field-level `visibleWhen` is the
predicate the renderer already evaluates for every other type-conditional
control in these files, so the split uses it and the form DSL is unchanged.

The pinning test asserts more than the absent value: it requires **exactly one**
visible control per type, because two declarations of one key can fail by
overlapping (two selects writing one key) as well as by leaving a gap (the
control vanishes for that type).
32 changes: 22 additions & 10 deletions packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
* metadata forms this repo actually SHIPS (`METADATA_FORM_REGISTRY`), which is
* both the corpus count the widening discipline requires before an `error`-level
* gate may land, and the reverse verification — restoring #6254's pre-fix
* `object.form.ts` spellings must turn the count from 0 to 16.
* `object.form.ts` spellings must turn the count from 0 to the number of
* `data.type`-rooted predicates that form ships today (17; #6254 itself
* measured 16, and #11410 added the second `deleteBehavior` declaration).
*/

import { describe, it, expect } from 'vitest';
Expand Down Expand Up @@ -474,7 +476,7 @@ describe('registry wiring', () => {
// ────────────────────────────────────────────────────────────────────────────
//
// `METADATA_FORM_REGISTRY` is the whole population of `data.`-rooted predicates
// this repo ships (17 forms; 46 predicates, all `visibleWhen`). Every entry is a
// this repo ships (17 forms; 48 predicates, all `visibleWhen`). Every entry is a
// `defineForm` output, i.e. exactly the bare FormView shape `formViewSites`
// walks as its `self` rung — so the corpus is fed through the rule's PRODUCTION
// entry point, not through a parallel walker written for the test.
Expand All @@ -498,7 +500,7 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// this way. The obvious version — "resolve every form against an empty
// schema, expect a finding per predicate" — is WRONG here and was measured
// wrong: an empty schema also fails to resolve the `fields` repeater, so the
// walk stops before the 16 sub-field predicates and the count comes out
// walk stops before the 17 sub-field predicates and the count comes out
// BELOW the corpus while looking like proof of reach.
//
// So keep the real schemas (descent works exactly as in production) and
Expand Down Expand Up @@ -529,7 +531,7 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
for (const value of Object.values(rec)) corrupt(value);
};
corrupt(corrupted.views);
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(46);
expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(48);

const findings = validatePredicatePathRefs(corrupted);
expect(findings).toHaveLength(predicates);
Expand Down Expand Up @@ -596,7 +598,7 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
for (const value of Object.values(rec)) rewrite(value);
};
rewrite(corrupted.views);
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(45);
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(47);

const rhsFindings = validatePredicatePathRefs(corrupted)
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);
Expand All @@ -607,9 +609,16 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
it('catches the pre-#6254 bare spellings when they are restored (reverse verification)', () => {
// The reverse direction is RED-on-restore: #6254 rewrote 16 predicates in
// `object.form.ts` from `type ...` to `data.type ...`. Restoring the bare
// spelling on a deep copy of the shipped `object` form must produce exactly
// 16 `predicate-path-unrooted` findings — the count the issue measured, and
// the count this rule exists to have caught before it shipped.
// spelling on a deep copy of the shipped `object` form must produce one
// `predicate-path-unrooted` finding per `data.type`-rooted predicate the
// form ships — the class the issue measured, and the class this rule exists
// to have caught before it shipped.
//
// The count tracks the CORPUS, not the issue: it is 17 today because
// #11410 split `deleteBehavior` into two declarations with disjoint
// `visibleWhen` (`lookup` / `master_detail`), so a `master_detail` author is
// no longer offered a `set_null` the schema refuses. #6254's own measurement
// was 16 and stays 16 — that number is history, this one is a census.
const objectForm = structuredClone(METADATA_FORM_REGISTRY.object) as Record<string, unknown>;
let restored = 0;
const debare = (node: unknown): void => {
Expand All @@ -636,10 +645,13 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
for (const value of Object.values(rec)) debare(value);
};
debare(objectForm);
expect(restored, 'the 16 sites #6254 rewrote are no longer where this test looks').toBe(16);
expect(
restored,
"the object form's `data.type`-rooted predicates are no longer where this test looks",
).toBe(17);

const findings = validatePredicatePathRefs({ views: [objectForm] });
expect(findings).toHaveLength(16);
expect(findings).toHaveLength(17);
expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([PREDICATE_PATH_UNROOTED]));
expect(findings[0].message).toContain('`type`');
});
Expand Down
27 changes: 26 additions & 1 deletion packages/spec/src/data/field.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,32 @@ export const fieldForm = defineForm({
{ field: 'options', type: 'repeater', visibleWhen: "data.type == 'select' || data.type == 'multiselect'", helpText: 'Available options (label/value pairs)' },
// Reference field options
{ field: 'reference', widget: 'ref:object', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'Referenced object name' },
{ field: 'deleteBehavior', visibleWhen: "data.type == 'lookup' || data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted' },
// Two declarations of one key, with disjoint `visibleWhen` (#11410) —
// the same split `object.form.ts` carries, and for the same reason:
// #9689 made `set_null` on a `master_detail` a parse-time rejection, so
// one shared control offered a choice publish refuses.
//
// This file reached that defect by the OTHER route. It declares no
// `options`, which is not a narrower offer but the renderer's DERIVED
// source: with no inline list the metadata-admin form falls through to
// the JSON Schema `enum` — `['set_null','cascade','restrict']`, which
// additionally advertises `default: 'set_null'`. A Zod enum has no
// per-type narrowing to give, so `master_detail` can only be served by
// an EXPLICIT list; omitting one re-offers the refused value.
//
// `lookup` keeps deriving from that enum, untouched: all three outcomes
// are legal there, and leaving the source alone keeps its labels and
// their translation exactly as they are today. The two branches are
// mutually exclusive, so no author ever sees both spellings at once.
//
// `helpText` is identical on both — they share one i18n key
// (`metadataForms.field.fields.deleteBehavior`), so divergent text would
// collide silently.
{ field: 'deleteBehavior', visibleWhen: "data.type == 'lookup'", helpText: 'What happens when referenced record is deleted' },
{ field: 'deleteBehavior', type: 'select', visibleWhen: "data.type == 'master_detail'", helpText: 'What happens when referenced record is deleted', options: [
{ label: 'Cascade (delete children)', value: 'cascade' },
{ label: 'Restrict (block the delete)', value: 'restrict' },
] },
],
},
{
Expand Down
Loading
Loading