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
32 changes: 32 additions & 0 deletions .changeset/field-deletebehavior-reference-only.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
---
"@objectstack/spec": minor
---

feat(spec): materialize `deleteBehavior` only on reference field types (#9784)

`FieldSchema` no longer materializes the `deleteBehavior: 'set_null'` default
onto non-reference field types (`text` / `datetime` / `number` / every other
non-relational type, `user` included). The key has no meaning there — the
engine's `cascadeDeleteRelations` reads it exclusively on `master_detail` /
`lookup` fields carrying a `reference` — yet the materialized default shipped
in every built app artifact, where a parse-time default becomes an apparent
explicit declaration downstream (the #4447 shadowing mechanism) and reads as
meaningful to AI authors browsing the artifact.

What changes and what does not:

- **Bare non-reference fields** parse to output that **omits** `deleteBehavior`
(previously: `deleteBehavior: 'set_null'` materialized on every type). Built
artifacts thin accordingly — measured on the showcase app: 210 fields, the
key drops from 206 fields to 16.
- **`lookup` and `tree`** keep materializing `set_null` byte-identically, at
shape position.
- **`master_detail`** keeps omitting it (the #9689 idempotent-materialization
ruling, unchanged).
- **The accept-set is untouched**: an authored `deleteBehavior` on any field
type parses exactly as before and round-trips verbatim, so artifacts built
by earlier versions (which carry the materialized key on every field) remain
fully legal inputs. `parse(parse(x))` holds across the boundary.

No authored metadata needs any change: no key is removed, renamed or
re-shaped, and no authoring spelling that parsed before is refused now.
Original file line numberDiff line numberDiff line change
Expand Up@@ -129,6 +129,11 @@ function makeStubEngine() {
* spelled `false` on two of the four and left ABSENT on the other two, because
* absent defaults to false as well and the read must not be right only for the
* spelling that happens to be explicit.
*
* `deleteBehavior: 'set_null'` on the datetime is the PRE-#9784 materializing
* era — newly built artifacts omit the key on non-reference fields, but
* artifacts of this shape remain in the installed base and must keep loading;
* keep the bytes as they shipped.
*/
const artifactObject = (name: string) => ({
name,
Expand Down
7 changes: 6 additions & 1 deletion packages/objectql/src/engine-audit-anchor-write.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -244,7 +244,12 @@ describe('[#4447] a declared audit field cannot loosen the platform posture', ()
fields: {
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
title: { name: 'title', label: 'Title', type: 'text' as const },
// Verbatim from examples/app-showcase/dist/objectstack.json.
// Verbatim from the PRE-#9784 examples/app-showcase/dist/objectstack.json
// — the materializing era, when FieldSchema baked `deleteBehavior:
// 'set_null'` onto every type. Newly built artifacts omit the key on
// non-reference fields (#9784), but artifacts of this shape remain in
// the installed base and this suite pins that they cannot loosen audit
// governance — keep the bytes as they shipped.
created_at: {
label: 'Created At', type: 'datetime' as const, required: false,
searchable: false, multiple: false, unique: false,
Expand Down
87 changes: 79 additions & 8 deletions packages/spec/src/data/field.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -492,8 +492,10 @@ describe('FieldSchema', () => {
// default the schema itself would refuse as authored — a bare
// `master_detail` parses to output that OMITS `deleteBehavior`, so
// `parse(parse(x))` holds on the mainline `create()` → `defineStack`
// path; every OTHER type keeps byte-identity with the `.default()` era,
// which is what the rest of this block pins.
// path. The reference types that still materialize (`lookup`/`tree`)
// keep byte-identity with the `.default()` era — that half is pinned
// here; #9784 (the block below) gates materialization off every
// NON-reference type.
describe('[#9689] deleteBehavior: set_null on master_detail is a parse-time rejection', () => {
const md = (extra: Record<string, unknown> = {}) => ({
name: 'parent_id',
Expand DownExpand Up@@ -571,19 +573,21 @@ describe('FieldSchema', () => {
expect(FieldSchema.parse({ ...lookup, required: true }).deleteBehavior).toBe('set_null');
});

it('keeps non-reference types accepting and defaulting the key (installed-base artifact shape, #4447)', () => {
// Verbatim shape from examples/app-showcase/dist/objectstack.json — a
// materialized datetime carrying only FieldSchema defaults. Built
// artifacts ship this on EVERY field type; it must stay legal.
it('keeps non-reference types ACCEPTING the key (installed-base artifact shape, #4447) — materialization moved to the #9784 block below', () => {
// Verbatim shape from the pre-#9784 examples/app-showcase/dist/
// objectstack.json — a materialized datetime carrying only FieldSchema
// defaults. Built artifacts of the materializing era ship this on
// EVERY field type; it must STAY legal (accept-set unchanged), and the
// authored value must round-trip verbatim, even though a bare
// datetime no longer materializes it.
const showcaseVerbatim = {
label: 'Created At', type: 'datetime', required: false,
searchable: false, multiple: false, unique: false,
deleteBehavior: 'set_null', hidden: false,
readonly: false, sortable: true, externalId: false,
};
expect(() => FieldSchema.parse(showcaseVerbatim)).not.toThrow();
// And a bare text field still gets the materialized default.
expect(FieldSchema.parse({ name: 'title', label: 'Title', type: 'text' }).deleteBehavior).toBe('set_null');
expect(FieldSchema.parse(showcaseVerbatim).deleteBehavior).toBe('set_null');
});

it('keeps FieldSchema.shape enumerable (no pipe degradation from the relocation)', () => {
Expand All@@ -595,6 +599,73 @@ describe('FieldSchema', () => {
});
});

// [#9784] `deleteBehavior` materializes ONLY on reference types. On every
// other type the key was inert by construction — the engine's
// `cascadeDeleteRelations` reads it exclusively behind a
// `master_detail`/`lookup` + `fdef.reference` guard — yet the materialized
// default shipped in every built artifact as an apparent explicit
// declaration (#4447 mechanism) and read as meaningful to AI authors
// (ADR-0033 direction). The accept-set is UNTOUCHED: authored values on
// any type round-trip verbatim (the installed-base test above).
describe('[#9784] deleteBehavior materializes only on reference types', () => {
const bare = (type: string) => ({ name: 'f1', label: 'F1', type });

it('omits deleteBehavior from bare non-reference fields (text/datetime/number)', () => {
for (const type of ['text', 'datetime', 'number']) {
const result = FieldSchema.parse(bare(type));
expect(result.deleteBehavior, `type=${type}`).toBeUndefined();
expect('deleteBehavior' in result, `type=${type}`).toBe(false);
}
});

it('omits deleteBehavior from bare `user` fields — outside today\'s cascade guard, same as text', () => {
// `user` is stored identically to `lookup` but the engine's cascade
// guard admits only `master_detail`/`lookup`, so the key is inert on
// `user` exactly as on `text`. It takes the non-reference side of the
// line; an authored value still round-trips (below).
const result = FieldSchema.parse({ ...bare('user'), reference: 'sys_user' });
expect('deleteBehavior' in result).toBe(false);
});

it('still materializes set_null on bare lookup and tree, at shape position (byte-identity)', () => {
// Key ORDER is part of the byte-identity contract (#4447):
// `deleteBehavior` sits between `reference` and `hidden` in the shape.
const lookupJson = JSON.stringify(FieldSchema.parse({
name: 'account_id', label: 'Account', type: 'lookup', reference: 'account',
}));
expect(lookupJson).toContain('"reference":"account","deleteBehavior":"set_null","hidden":false');
const treeJson = JSON.stringify(FieldSchema.parse({
name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category',
}));
expect(treeJson).toContain('"reference":"category","deleteBehavior":"set_null","hidden":false');
});

it('keeps an AUTHORED deleteBehavior on non-reference types, verbatim (accept-set unchanged)', () => {
expect(FieldSchema.parse({ ...bare('text'), deleteBehavior: 'cascade' }).deleteBehavior).toBe('cascade');
expect(FieldSchema.parse({ ...bare('number'), deleteBehavior: 'restrict' }).deleteBehavior).toBe('restrict');
expect(FieldSchema.parse({ ...bare('datetime'), deleteBehavior: 'set_null' }).deleteBehavior).toBe('set_null');
expect(FieldSchema.parse({ ...bare('user'), deleteBehavior: 'set_null' }).deleteBehavior).toBe('set_null');
});

it('parse(parse(x)) is byte-stable for bare and authored spellings across the type boundary', () => {
const cases = [
bare('text'),
bare('datetime'),
bare('number'),
{ ...bare('user'), reference: 'sys_user' },
{ name: 'account_id', label: 'Account', type: 'lookup', reference: 'account' },
{ name: 'parent_id', label: 'Parent', type: 'tree', reference: 'category' },
{ ...bare('text'), deleteBehavior: 'cascade' },
{ name: 'account_id', label: 'Account', type: 'lookup', reference: 'account', deleteBehavior: 'restrict' },
];
for (const input of cases) {
const once = FieldSchema.parse(input);
const twice = FieldSchema.parse(once);
expect(JSON.stringify(twice), `type=${(input as { type: string }).type}`).toBe(JSON.stringify(once));
}
});
});

it('should accept the relatedList prominence tri-state (false | true | primary)', () => {
for (const relatedList of [false, true, 'primary'] as const) {
const field: Field = {
Expand Down
43 changes: 38 additions & 5 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1002,6 +1002,16 @@ export const FieldSchema = lazySchema(() => {
* `.default('set_null')` era. The `default` annotation states the contract
* default to schema consumers without touching parse order — the
* `autonumberFormat` pattern below.
*
* #9784 — the `.overwrite` materializes the default ONLY on the reference
* types where the key has meaning (`lookup` / `tree`; `master_detail` omits
* it per the #9689 idempotent-materialization ruling). On every other type
* the key was inert by construction — the engine reads it exclusively
* behind a `master_detail`/`lookup` + `reference` guard — yet the
* materialized value shipped in every built artifact as an apparent
* explicit declaration (the #4447 shadowing mechanism). A bare `text` /
* `datetime` / `number` field now parses to output WITHOUT the key; an
* authored value on any type is preserved verbatim (accept-set unchanged).
*/
deleteBehavior: z.enum(['set_null', 'cascade', 'restrict']).optional().meta({
description: 'What happens if referenced record is deleted',
Expand DownExpand Up@@ -1708,11 +1718,13 @@ export const FieldSchema = lazySchema(() => {
// the superRefine above always sees the pre-materialized value. The key is
// re-inserted at its SHAPE position (Zod emits parse output in shape
// order), so output is byte-identical to the `.default('set_null')` era on
// every field type EXCEPT `master_detail` — see the ruling below. The one
// accepted cost, same as the currency precedent's: the INFERRED output
// type now declares `deleteBehavior?` even though a parsed non-
// `master_detail` field always carries it (ADR-0122 forbids hand-narrowing
// the inferred type); the runtime contract is the enforced one.
// the reference types that still materialize it (`lookup` / `tree`) — see
// the two rulings below for why `master_detail` and every non-reference
// type omit it instead. The one accepted cost, same as the currency
// precedent's: the INFERRED output type declares `deleteBehavior?` even
// though a parsed `lookup`/`tree` field always carries it (ADR-0122
// forbids hand-narrowing the inferred type); the runtime contract is the
// enforced one.
if (field.deleteBehavior !== undefined) return field;
// #9689 (maintainer ruling 2026-08-24, idempotent materialization —
// 「四维分析一致的,接手你的建议。」): NEVER materialize a default the
Expand All@@ -1731,6 +1743,27 @@ export const FieldSchema = lazySchema(() => {
// byte-identity, and the #7918 currency `precision` twin of this landmine
// is #11423 — same principle, its own card.
if (field.type === 'master_detail') return field;
// #9784 — materialize the default ONLY on reference types. `deleteBehavior`
// has no meaning on a non-reference field: the engine's
// `cascadeDeleteRelations` reaches the key exclusively on
// `master_detail`/`lookup` fields carrying a `reference`
// (`packages/objectql/src/engine.ts`, the type + `fdef.reference` guards),
// so on a `text`/`datetime`/`number` field the materialized value was
// inert by construction — yet it shipped in every built app artifact,
// where a default materialized at parse becomes an EXPLICIT declaration
// downstream (the #4447 shadowing mechanism), and where an AI author
// reading the artifact reasonably concludes the key is meaningful there
// (ADR-0033 direction). Non-reference fields therefore parse to output
// that OMITS the key. The accept-set is untouched: an AUTHORED
// `deleteBehavior` on any type still parses exactly as before (the
// `!== undefined` early return above), so stored artifacts from the
// materializing era stay legal. `tree` (hierarchical reference) keeps
// materializing with `lookup`: it is in the relational family, where the
// key states delete semantics — the conservative byte-identity side of
// the line. `user` is stored identically to `lookup` but sits outside
// today's cascade guard exactly like `text` does, so it takes the
// non-reference side; an authored value there still round-trips.
if (field.type !== 'lookup' && field.type !== 'tree') return field;
const withDefault: Record<string, unknown> = { ...field, deleteBehavior: 'set_null' };
const out: Record<string, unknown> = {};
for (const key of shapeOrder) {
Expand Down
Loading