diff --git a/.changeset/23065-referenceless-tree.md b/.changeset/23065-referenceless-tree.md new file mode 100644 index 00000000000..9c997c8f556 --- /dev/null +++ b/.changeset/23065-referenceless-tree.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': patch +--- + +fix(spec, objectql): a `tree` field with no `reference` points at its own object, so its `deleteBehavior` takes effect when a parent is deleted + +Clause-②: yes (widening) + +A `tree` field is a record's parent in a hierarchy on its own object. `reference` is optional on it, and when you write one it must be the object's own name — the object schema refuses any other target and tells you to drop the key. But a `tree` with no `reference` behaved as if it pointed at nothing. Deleting a parent answered success and left every child holding the deleted id, whatever `deleteBehavior` said (`set_null`, `restrict` or `cascade`). The same field also could not be expanded (`expand=parent` answered "declares no target object"), could not be filtered through (`{ parent: { title: … } }` was refused), and accepted a parent id that does not exist. + +Now a `tree` with no `reference` is a reference to the object that declares it, exactly as if you had written the object's name: + +- **`@objectstack/spec`.** `ObjectSchema` and `ObjectExtensionSchema` write the object's own name (an extension's `extend`) into such a field's `reference` when they parse it. `defineStack` and `ObjectSchema.create()` output, and built artifacts, now carry the object's name in `reference` on those fields, byte-identical to a field that wrote it. Nothing you could write before is refused now. A new export, `materializeTreeSelfReferences(objectName, fields)`, is the one helper both the parse and the engine use. +- **`@objectstack/objectql`.** The object registry writes the same self-reference when an object is registered. That covers an object saved through the metadata API (`PUT /meta/object/:name` stores the body as written) and a direct `registerObject` call, neither of which goes through the parse. + +What changes on a parent delete: + +- `set_null` (the default) clears the children's parent field and keeps the children. +- `restrict` refuses the delete with `DELETE_RESTRICTED` (409), naming the tree's own object as the dependent. +- `cascade` deletes the whole subtree. + +`expand`, nested relation filters, the write-time check that a parent id exists, the dangling-reference audit and `os verify --delete-behaviors` now treat the field as a self-reference too. A `tree` that already wrote its own name, and every `lookup` and `master_detail`, behave exactly as before. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 574c95de81d..3d2ae2bf240 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -76,7 +76,7 @@ const result = CurrencyConfigSchema.parse(data); | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index ce4c958f999..00e107f2841 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -241,7 +241,7 @@ const result = ApiMethod.parse(data); | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | @@ -583,7 +583,7 @@ const result = ApiMethod.parse(data); | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index e301d3d7175..01dfe5b8d24 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -77,7 +77,7 @@ Add a new field to an existing object | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | @@ -504,7 +504,7 @@ Add a new field to an existing object | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | | **deleteBehavior** | `Enum<'set_null' \| 'cascade' \| 'restrict'>` | optional (default: `"set_null"`) | What happens if referenced record is deleted | diff --git a/packages/objectql/src/engine-cascade-delete-tree.test.ts b/packages/objectql/src/engine-cascade-delete-tree.test.ts index 4bdba434b26..78950066ad9 100644 --- a/packages/objectql/src/engine-cascade-delete-tree.test.ts +++ b/packages/objectql/src/engine-cascade-delete-tree.test.ts @@ -36,7 +36,11 @@ * registration around it is read single-valued, as the walk's multi-value * arbiter (`isMultiValueField`) reads it for every type; * - CONTROLS: `lookup` (default `set_null`, `cascade`, `restrict`) and - * `master_detail` (default `cascade`, `restrict`) answer exactly as before. + * `master_detail` (default `cascade`, `restrict`) answer exactly as before; + * - [#23065] a `tree` that names no `reference` is a reference to its own + * object: the registry writes that self-reference at registration (the door + * a metadata save and a raw registration reach without the spec's parse), so + * every behaviour above takes effect on it too. * * The driver has REAL snapshot rollback (the `engine-cascade-delete-atomic` * shape): a stub whose rollback is a no-op cannot tell "refused and rolled @@ -44,7 +48,7 @@ */ import { describe, it, expect } from 'vitest'; -import { FieldSchema } from '@objectstack/spec/data'; +import { FieldSchema, ObjectSchema, referenceTargetOf } from '@objectstack/spec/data'; import { ObjectQL } from './engine.js'; type Row = Record; @@ -403,3 +407,106 @@ describe('[#22957] a tree field\'s deleteBehavior takes effect on a parent delet }); }); }); + +// --------------------------------------------------------------------------- +// [#23065] A `tree` field that names no `reference` references the object that +// declares it. The spec's object parse writes that down, and so does the +// registry, for the doors that reach it without the parse: a metadata save +// stores the authored body verbatim, and a raw `registerObject` is never +// parsed. Before, no reader could read the target off such a field, so the walk +// above never reached it: the parent delete answered success and the child kept +// the deleted id, whatever `deleteBehavior` said. +// --------------------------------------------------------------------------- + +describe('[#23065] a tree field that names no reference is a reference to its own object', () => { + /** The tree field with no `reference`, as a raw registration carries it. */ + const bare = (extra: Record = {}) => ({ type: 'tree', ...extra }); + + it('the registry writes the self-reference at registration, and leaves the caller\'s definition alone', async () => { + const definition = treeObject('zr_node', bare()); + const { engine } = await makeEngine([definition]); + expect((engine.registry.getObject('zr_node') as any)?.fields?.parent?.reference).toBe('zr_node'); + // The registered copy carries it; the object the caller handed in does not. + expect(definition.fields.parent).not.toHaveProperty('reference'); + }); + + it('an extension\'s tree references the object it extends', async () => { + const { engine } = await makeEngine([{ name: 'zr_base', label: 'Base', fields: { id, name } }]); + engine.registry.registerObject( + { name: 'zr_base', fields: { parent_node: { name: 'parent_node', type: 'tree' } } } as any, + '__ext__', undefined, 'extend', + ); + expect((engine.registry.getObject('zr_base') as any)?.fields?.parent_node?.reference).toBe('zr_base'); + }); + + it.each([ + ['absent (a raw registration)', bare()], + ['materialized by the spec\'s field parse', FieldSchema.parse({ type: 'tree' }) as Record], + ['authored', bare({ deleteBehavior: 'set_null' })], + ])('set_null, %s: deleting the parent clears the child\'s parent and keeps the child', async (_label, def) => { + const { engine, driver } = await makeEngine([treeObject('zr_node', def)]); + const p = await engine.insert('zr_node', { name: 'P' }); + const c = await engine.insert('zr_node', { name: 'C', parent: p.id }); + const bystander = await engine.insert('zr_node', { name: 'B', parent: c.id }); + + await engine.delete('zr_node', { where: { id: p.id } } as any); + + expect(driver.rowOf('zr_node', p.id)).toBeUndefined(); + expect(driver.rowOf('zr_node', c.id)).toMatchObject({ name: 'C', parent: null }); + expect(driver.rowOf('zr_node', bystander.id)).toMatchObject({ name: 'B', parent: c.id }); + }); + + it('the object the spec\'s parse produced registers the same way (the declared-stack door)', async () => { + const parsed = ObjectSchema.parse({ name: 'zr_parsed', fields: { name: { type: 'text' }, parent: { type: 'tree' } } }); + expect(parsed.fields.parent.reference).toBe('zr_parsed'); + const { engine, driver } = await makeEngine([parsed]); + const p = await engine.insert('zr_parsed', { name: 'P' }); + const c = await engine.insert('zr_parsed', { name: 'C', parent: p.id }); + + await engine.delete('zr_parsed', { where: { id: p.id } } as any); + + expect(driver.rowOf('zr_parsed', c.id)).toMatchObject({ name: 'C', parent: null }); + }); + + it('restrict refuses with the envelope naming the tree\'s own object, and keeps both rows', async () => { + const { engine, driver } = await makeEngine([treeObject('zr_held', bare({ deleteBehavior: 'restrict' }))]); + const p = await engine.insert('zr_held', { name: 'P' }); + const c = await engine.insert('zr_held', { name: 'C', parent: p.id }); + + const err = await refusalOf(engine.delete('zr_held', { where: { id: p.id } } as any)); + + expect(err).toMatchObject({ code: 'DELETE_RESTRICTED', status: 409, dependentObject: 'zr_held' }); + expect(driver.rowOf('zr_held', p.id)).toMatchObject({ name: 'P' }); + expect(driver.rowOf('zr_held', c.id)).toMatchObject({ name: 'C', parent: p.id }); + }); + + it('cascade deletes the subtree, and nothing outside it, as one unit of work', async () => { + const { engine, driver } = await makeEngine([treeObject('zr_branch', bare({ deleteBehavior: 'cascade' }))]); + const root = await engine.insert('zr_branch', { name: 'root' }); + const a = await engine.insert('zr_branch', { name: 'a', parent: root.id }); + await engine.insert('zr_branch', { name: 'b', parent: a.id }); + await engine.insert('zr_branch', { name: 'a2', parent: root.id }); + + await engine.delete('zr_branch', { where: { id: a.id } } as any); + + expect(namesOf(driver.rowsOf('zr_branch'))).toEqual(['a2', 'root']); + expect(driver.committed).toHaveLength(1); + expect(driver.rolledBack).toHaveLength(0); + }); + + it('an empty-string reference is not written over: it reaches the registry as written, and names nothing', async () => { + // The parse refuses `reference: ''` on a tree (`refuseForeignTreeReference`), + // so the registry, which never parses, must not repair it into a valid + // self-reference: the two doors give the shape one reading. The carrier + // reader then reads it as naming nothing, as it did before. + const { engine } = await makeEngine([treeObject('zr_empty', { type: 'tree', reference: '' })]); + const parent = (engine.registry.getObject('zr_empty') as any)?.fields?.parent; + expect(parent?.reference).toBe(''); + expect(referenceTargetOf(parent)).toBeUndefined(); + }); + + it('CONTROL: a tree that wrote its own name registers exactly as written', async () => { + const { engine } = await makeEngine([treeObject('zr_written', { type: 'tree', reference: 'zr_written' })]); + expect((engine.registry.getObject('zr_written') as any)?.fields?.parent?.reference).toBe('zr_written'); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index b4b1c5441c9..d6a85987790 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data'; +import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveInjectedSystemColumns, isInjectedColumnDefinition, isTenancyDisabled, checkManagedApiMethodAffordances, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, materializeTreeSelfReferences } from '@objectstack/spec/data'; // [#4513] The audit-family governance table, and [#6562] the injected-column // DEFINITION tables it governs — see the re-exports below for why both live in a // package `objectql` and `metadata-protocol` both depend on. @@ -2667,6 +2667,20 @@ export class SchemaRegistry { const fieldTypeRefusal = objectFieldTypeRefusal(schema); if (fieldTypeRefusal) throw fieldTypeRefusal; + // [#23065] A `tree` field that names no `reference` references the object + // that declares it, and this is where that is written down for every door + // the parse does not cover: a metadata save stores the authored body + // verbatim and the `sys_metadata` rehydration and a raw `registerObject` + // never parse at all. `ObjectSchema`'s parse writes the same thing through + // the same spec helper, so the two doors cannot disagree, and every reader + // of the registered schema — the referential delete walk, the write-time + // reference check, `expand`, nested relation conditions — sees an ordinary + // self-reference. ⛔ No reader resolves it on its own. An extension + // registers under the object it extends (`name: ext.extend`), which is the + // object its trees reference. + const treeFields = materializeTreeSelfReferences(schema.name, schema.fields); + if (treeFields !== schema.fields) schema = { ...schema, fields: treeFields }; + // Apply system-field injection (multi-tenant org_id, future owner/audit) // BEFORE FQN computation and contributor storage so every consumer of // the registered schema (driver syncSchema, REST projector, hooks) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index b0efb6ccda0..1bc71a3972c 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -906,6 +906,7 @@ "lowerFilterCondition (function)", "markFilterSubtreeProvenance (function)", "matchesLikePattern (function)", + "materializeTreeSelfReferences (function)", "missingFieldValues (function)", "nextUtcCalendarDay (function)", "normalizeFilterComparandTypes (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index b0add27ab08..63a37f65bc5 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -892,6 +892,7 @@ "lowerFilterCondition": "src/data/filter-lowering.ts#lowerFilterCondition (function)", "markFilterSubtreeProvenance": "src/data/filter-subtree-provenance.ts#markFilterSubtreeProvenance (function)", "matchesLikePattern": "src/data/filter.zod.ts#matchesLikePattern (function)", + "materializeTreeSelfReferences": "src/data/object.zod.ts#materializeTreeSelfReferences (function)", "missingFieldValues": "src/data/autonumber-format.ts#missingFieldValues (function)", "nextUtcCalendarDay": "src/data/calendar-day.ts#nextUtcCalendarDay (function)", "normalizeFilterComparandTypes": "src/data/filter-comparand-type.ts#normalizeFilterComparandTypes (function)", diff --git a/packages/spec/src/data/field-value.zod.ts b/packages/spec/src/data/field-value.zod.ts index 528f88597c3..8372194ccad 100644 --- a/packages/spec/src/data/field-value.zod.ts +++ b/packages/spec/src/data/field-value.zod.ts @@ -178,9 +178,22 @@ const IMPLICIT_REFERENCE_TARGETS: ReadonlyMap = new Map([ * engine that performs it alike. * * Returns `undefined` only when the field genuinely names no target: a - * non-reference type, or a `lookup`/`master_detail`/`tree` with no `reference` - * (an authoring bug — those types carry an author-chosen target and nothing - * can supply it for them). + * non-reference type, or a `lookup`/`master_detail` with no `reference` (an + * authoring bug — those types carry an author-chosen target, nothing can supply + * it for them, and `FieldSchema` refuses the omission). + * + * A `tree` with no `reference` is NOT that case (#23065). Its target is the + * object that declares it — a hierarchy is parent/child within one object, and + * `refuseForeignTreeReference` tells authors to drop the key — but that is a + * fact of the field's position, which a reader of one field cannot see. So it + * is written onto the field where the object is known, and not derived here: + * `ObjectSchema` / `ObjectExtensionSchema`'s parse and the engine's object + * registry give the field its own object's name (`materializeTreeSelfReferences`, + * `object.zod.ts`), and a reader of a parsed or registered object receives an + * ordinary self-reference. This function answers `undefined` for a bare + * `{ type: 'tree' }` only because no object came with it. ⛔ A caller that holds + * the object does not patch the gap with its own fallback: it reads the object + * through one of those doors. * * Framework#4443 / cloud#983: the two callers used to read `field.reference` * raw, which made a `{ type: 'user' }` field targetless to BOTH — the expand diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 2a30495308c..7a7eafb0c41 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1433,12 +1433,17 @@ export const FieldSchema = lazySchema(() => { * judge it — a field does not know which object declares it — so the * refusal of any other target lives on `ObjectSchema` / `ObjectExtensionSchema` * (`object.zod.ts`, `refuseForeignTreeReference`), where the own name is known. + * Absent, it is written there too (#23065, `materializeTreeSelfReferences`): + * the object's parse gives the field its own object's name, and so does the + * engine's object registry for a definition that reaches it unparsed, so every + * reader of the target — the delete walk that honours `deleteBehavior`, + * `$expand`, the write-time reference check — sees an ordinary self-reference. */ reference: z.string().optional().describe( 'Target object name (snake_case) for lookup/master_detail fields. ' + 'Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. ' + 'On a `tree` field it is optional and, if given, must be the declaring object\'s own name — ' - + 'the object schema refuses any other target.' + + 'the object schema refuses any other target, and writes the own name when it is left out.' ), /** * Polymorphic pointer declaration (ADR-0052 §5 — the ActivityPointer model). @@ -1529,9 +1534,12 @@ export const FieldSchema = lazySchema(() => { * #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 + * the key was inert by construction — the engine's referential delete walk + * reads it only on a `master_detail`, `lookup` or `tree` field that carries a + * `reference` (the family predicate `statesDeleteSemantics` in + * `packages/objectql/src/engine.ts`; a `tree`'s `reference` is its own + * object, written by the object's parse when the author leaves it out) — 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). @@ -2799,9 +2807,12 @@ export const FieldSchema = lazySchema(() => { // // #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), + // `cascadeDeleteRelations` reaches the key only on `master_detail`, + // `lookup` and `tree` fields carrying a `reference` + // (`packages/objectql/src/engine.ts`: the family predicate + // `statesDeleteSemantics` + the carrier guard; a `tree` with no + // `reference` carries its own object's name once the object's parse or the + // engine's object registry has written it, #23065), // 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 @@ -2813,10 +2824,10 @@ export const FieldSchema = lazySchema(() => { // `=== undefined` guard), 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. + // semantics, and the delete walk reads it there (#22957). `user` is + // stored identically to `lookup` but sits outside the delete walk's family + // (`master_detail`, `lookup`, `tree`) exactly like `text` does, so it takes + // the non-reference side; an authored value there still round-trips. if (field.deleteBehavior === undefined && (field.type === 'lookup' || field.type === 'tree')) { patch.deleteBehavior = 'set_null'; } diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 7ea6048e3c3..11f7a53b263 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1645,6 +1645,91 @@ function refuseForeignTreeReference(ownName: unknown, fields: unknown, ctx: z.Re } } +/** + * [#23065] The field map with every `tree` field that names no `reference` + * given the declaring object's name as its `reference` — the self-reference the + * contract already reads it as, written down. + * + * A `tree` is parent/child WITHIN one object ({@link refuseForeignTreeReference}), + * so its target is a fact of the field's POSITION, not of the field: `FieldSchema` + * sees one field and cannot supply it, and neither can `referenceTargetOf` + * (`field-value.zod.ts`), which reads a field alone. Left absent, every reader of + * the target answered "no target" for a declaration the contract accepts and + * whose refusal tells authors to write (`Drop \`reference\``): the referential + * delete walk skipped it, so the materialized `deleteBehavior` was inert and a + * parent delete left its children holding the deleted id; the write-time + * reference check, `expand` and nested relation conditions treated it as + * targetless. So the target is materialized where the declaring object's name + * IS known, and every reader then sees an ordinary self-reference — one reading, + * no reader-side fallback: + * + * - `ObjectSchema` and `ObjectExtensionSchema` run it as an `.overwrite` after + * their refinements (own name = `name` / `extend`), so every parsed object — + * `defineStack`, `ObjectSchema.create()`, a built artifact — carries it; + * - the engine's object registry runs it at registration, because a metadata + * save stores the authored body verbatim and a raw registration is never + * parsed, and both reach the registry without the parse's output. + * + * Absence is the two spellings a contract admits for "names no target": + * `undefined` (what `FieldSchema`'s `.optional()` admits) and `null` (what the + * blueprint's `StrictField` admits). Every other value is returned untouched, + * so the two doors cannot disagree about it: + * + * - `''` is NOT absence here. The parse refuses it on a `tree` as a foreign + * target (`refuseForeignTreeReference` passes only an absent key or the own + * name), so the registry leaves it as written rather than repairing, + * unparsed, a value the contract refuses; the carrier reader then reads it + * as naming nothing, exactly as before this helper existed; + * - a foreign name is `refuseForeignTreeReference`'s to refuse, and a + * non-string value is the carrier reader's to refuse loudly. + * + * The key lands at its `FieldSchema` shape position, + * so a parsed referenceless `tree` is byte-identical to one that wrote its own + * name, and a re-parse is a no-op. + * + * Returns `fields` itself when no field needs it, so the common case allocates + * nothing; otherwise a shallow copy with copies of the fields it stamped. + * + * @param ownName The declaring object's name (an extension's `extend`). + * @param fields The object's field map, keyed by field name. + */ +export function materializeTreeSelfReferences(ownName: unknown, fields: F): F { + if (typeof ownName !== 'string' || ownName === '') return fields; + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return fields; + let stamped: Record | undefined; + for (const [fieldName, def] of Object.entries(fields as Record)) { + if (def === null || typeof def !== 'object') continue; + const { type, reference } = def as { type?: unknown; reference?: unknown }; + if (type !== 'tree' || !(reference === undefined || reference === null)) continue; + stamped ??= { ...(fields as Record) }; + stamped[fieldName] = withReferenceAtShapePosition(def as Record, ownName); + } + return (stamped ?? fields) as F; +} + +/** + * `def` with `reference: target` inserted where `FieldSchema`'s shape declares + * the key: before the first key the shape declares after it. Parse output is in + * shape order, so the result is the parse output of the same field with + * `reference` written. + */ +function withReferenceAtShapePosition(def: Record, target: string): Record { + const order = Object.keys((FieldSchema as unknown as { shape: Record }).shape); + const declaredAfter = new Set(order.slice(order.indexOf('reference') + 1)); + const out: Record = {}; + let placed = false; + for (const [key, value] of Object.entries(def)) { + if (key === 'reference') continue; + if (!placed && declaredAfter.has(key)) { + out.reference = target; + placed = true; + } + out[key] = value; + } + if (!placed) out.reference = target; + return out; +} + /** * The field types an object's `imageField` may name: the two whose stored * value IS a picture. One list, read by the refusal below and spelled into its @@ -2758,6 +2843,13 @@ const ObjectSchemaBase = strictObject( // [#22211 ruling A] A read attachment names at least one leaf, and is not a // field: its block names may not repeat a declared field name. refuseAttachedBlockConflicts(object.attachedOnRead, object.fields, ctx); +}).overwrite((object) => { + // [#23065] A `tree` that names no `reference` references this object, and the + // parse writes that down after the refinements above judged what the author + // wrote. `.overwrite` keeps this a `ZodObject`, as `.superRefine` does. See + // {@link materializeTreeSelfReferences}. + const fields = materializeTreeSelfReferences(object.name, object.fields); + return fields === object.fields ? object : { ...object, fields }; }); /** @@ -3563,6 +3655,11 @@ export const ObjectExtensionSchema = lazySchema(() => strictObject({ // [#14892] The same rule as on `ObjectSchema`: the fields merge into // `extend`, so that is the object a `tree` field's `reference` must name. refuseForeignTreeReference(extension.extend, extension.fields, ctx); +}).overwrite((extension) => { + // [#23065] …and the object a referenceless `tree` field references. See + // {@link materializeTreeSelfReferences}. + const fields = materializeTreeSelfReferences(extension.extend, extension.fields); + return fields === extension.fields ? extension : { ...extension, fields }; })); export type ObjectExtension = z.input; diff --git a/packages/spec/src/data/tree-reference-self-only.test.ts b/packages/spec/src/data/tree-reference-self-only.test.ts index e21af883d1f..3a768de5b7d 100644 --- a/packages/spec/src/data/tree-reference-self-only.test.ts +++ b/packages/spec/src/data/tree-reference-self-only.test.ts @@ -1,8 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { ObjectSchema, ObjectExtensionSchema } from './object.zod'; +import { ObjectSchema, ObjectExtensionSchema, materializeTreeSelfReferences } from './object.zod'; import { FieldSchema } from './field.zod'; +import { referenceTargetOf } from './field-value.zod'; +import { defineStack } from '../stack.zod'; import { classifyDottedFilterHead } from './filter-dotted-head'; // --------------------------------------------------------------------------- @@ -55,7 +57,9 @@ describe('a `tree` field\'s `reference` must name the declaring object', () => { const parsed = ObjectSchema.safeParse(zoo()); expect(parsed.success, parsed.success ? '' : JSON.stringify(parsed.error.issues)).toBe(true); if (!parsed.success) return; - expect(parsed.data.fields.f_tree.reference).toBeUndefined(); + // [#23065] Omitted is accepted, and the parse writes the self-reference the + // omission means (the block below pins that reading in full). + expect(parsed.data.fields.f_tree.reference).toBe('showcase_field_zoo'); // The fifth reading (folded from #13928): a reference-less `tree` is // classified `relation` and still materialises `deleteBehavior` beside // `lookup` — coherent under this rule, because the hierarchy it declares @@ -144,3 +148,117 @@ describe('a `tree` field\'s `reference` must name the declaring object', () => { expect(firstSentence(foreign.error.issues[0].message)).toContain('`company`'); }); }); + +// --------------------------------------------------------------------------- +// [#23065] A `tree` field that names no `reference` references the object that +// declares it, and the doors that know that object WRITE it down. The contract +// already read the omission as a self-reference (the refusal above tells +// authors to drop the key, and `FieldSchema` materializes `set_null` on the +// field), but nothing could read the target off a field alone, so every reader +// answered "no target": the delete walk never reached the field, and a parent +// delete left its children holding the deleted id. The engine's registry runs +// the same helper for the doors this parse does not cover (its own pins). +// --------------------------------------------------------------------------- + +describe('a referenceless `tree` is written as a self-reference by the doors that know the object', () => { + it('the object parse writes the own name, byte-identical to a field that wrote it, and a re-parse is a no-op', () => { + const omitted = ObjectSchema.parse(zoo()); + const written = ObjectSchema.parse(zoo('showcase_field_zoo')); + expect(omitted.fields.f_tree.reference).toBe('showcase_field_zoo'); + // The same bytes, key order included: the key lands at its shape position. + expect(JSON.stringify(omitted)).toBe(JSON.stringify(written)); + expect(JSON.stringify(ObjectSchema.parse(omitted))).toBe(JSON.stringify(omitted)); + // The reader of one field now answers the target, as for any self-reference. + expect(referenceTargetOf(omitted.fields.f_tree)).toBe('showcase_field_zoo'); + }); + + it('the authoring door (create()) and the stack door (defineStack) write it too', () => { + const created = ObjectSchema.create({ + name: 'category', + fields: { name: { type: 'text', label: 'Name' }, parent: { type: 'tree', label: 'Parent' } }, + }); + // `create()` is typed off the authored literal, so the written key is read + // through the field's declared shape. + expect((created.fields.parent as { reference?: string }).reference).toBe('category'); + + const stack = defineStack({ + manifest: { id: 'com.example.tree', namespace: 'tre', version: '0.0.1', type: 'app', name: 'Tree' }, + objects: [{ name: 'tre_node', fields: { name: { type: 'text' }, parent: { type: 'tree', deleteBehavior: 'cascade' } } }], + }); + const parent = (stack.objects?.[0]?.fields as Record).parent; + expect(parent).toMatchObject({ reference: 'tre_node', deleteBehavior: 'cascade' }); + }); + + it('the extension door writes the object it extends', () => { + const ext = ObjectExtensionSchema.parse({ + extend: 'contact', + fields: { parent_contact: { type: 'tree', label: 'Parent' } }, + }); + expect(ext.fields?.parent_contact.reference).toBe('contact'); + }); + + it('the refusal\'s own remedy now holds: dropping a foreign `reference` yields the self-reference', () => { + const refused = ObjectSchema.safeParse(zoo('showcase_category')); + expect(refused.success).toBe(false); + if (refused.success) return; + expect(refused.error.issues[0].message).toContain('Drop `reference`'); + expect(ObjectSchema.parse(zoo()).fields.f_tree.reference).toBe('showcase_field_zoo'); + }); + + it('CONTROL: a written self-reference, a lookup and a master_detail parse exactly as before', () => { + // Each field, parsed inside the object, is exactly what `FieldSchema` alone + // makes of it: the object door wrote nothing onto any of them. + const fields = { + f_tree: { type: 'tree', label: 'Tree', reference: 'showcase_field_zoo' }, + f_lookup: { type: 'lookup', label: 'Account', reference: 'showcase_account' }, + f_master_detail: { type: 'master_detail', label: 'Project', reference: 'showcase_project' }, + f_text: { type: 'text', label: 'Text' }, + } as const; + const parsed = ObjectSchema.parse({ name: 'showcase_field_zoo', fields }); + for (const [name, def] of Object.entries(fields)) { + expect(JSON.stringify(parsed.fields[name]), name).toBe(JSON.stringify(FieldSchema.parse(def))); + } + expect(parsed.fields.f_text).not.toHaveProperty('reference'); + }); + + it('the helper: absence is `undefined` or `null`, every other value is untouched, nothing to write allocates nothing', () => { + const untouched = { name: { type: 'text' }, parent: { type: 'tree', reference: 'node' } }; + expect(materializeTreeSelfReferences('node', untouched)).toBe(untouched); + + const fields = { + a: { type: 'tree' }, + b: { type: 'tree', reference: null }, + c: { type: 'tree', reference: '' }, + d: { type: 'tree', reference: { object: 'node' } }, + e: { type: 'lookup' }, + }; + const out = materializeTreeSelfReferences('node', fields) as Record; + expect(out).not.toBe(fields); + expect([out.a.reference, out.b.reference]).toEqual(['node', 'node']); + // `''` is not absence here: the parse refuses it on a tree (the pin below), + // so the helper leaves it as written instead of repairing it unparsed. + expect(out.c).toBe(fields.c); + // A non-string carrier is the carrier reader's to refuse, loudly — never + // overwritten here, where it would read as a valid self-reference. + expect(out.d).toBe(fields.d); + expect(out.e).toBe(fields.e); + // The input is not mutated. + expect(fields.a).toEqual({ type: 'tree' }); + // Only an empty string to look at: nothing is written, nothing allocated. + const onlyEmpty = { parent: { type: 'tree', reference: '' } }; + expect(materializeTreeSelfReferences('node', onlyEmpty)).toBe(onlyEmpty); + + // No name, or no field map, is nothing to write against. + expect(materializeTreeSelfReferences(undefined, fields)).toBe(fields); + expect(materializeTreeSelfReferences('', fields)).toBe(fields); + const list = [{ name: 'parent', type: 'tree' }]; + expect(materializeTreeSelfReferences('node', list)).toBe(list); + }); + + it('`reference: \'\'` on a tree has one reading: the parse refuses it, and nothing writes over it', () => { + const parsed = ObjectSchema.safeParse(zoo('')); + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.map((i) => i.path)).toEqual([['fields', 'f_tree', 'reference']]); + }); +}); diff --git a/packages/verify/src/delete-behavior-referenceless-tree.test.ts b/packages/verify/src/delete-behavior-referenceless-tree.test.ts new file mode 100644 index 00000000000..0c4770b5fd4 --- /dev/null +++ b/packages/verify/src/delete-behavior-referenceless-tree.test.ts @@ -0,0 +1,234 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#23065] A `tree` field that names no `reference` is a reference to the +// object that declares it, and a delete of a parent does what its +// `deleteBehavior` declares — read at the public doors, on a real stack. +// +// The contract already read the omission that way: `ObjectSchema`'s refusal of +// a foreign target tells authors to drop `reference`, and `FieldSchema` +// materializes `set_null` on the field. But no reader could read a target off a +// field alone, so the referential delete walk never reached it. Measured at this +// door on `origin/main` `275ad4229a`: `DELETE` of the parent answered `200` and +// the child kept the deleted id under `set_null`, `restrict` and `cascade` +// alike, while the same field written with its own object's name cleared. +// +// Now the doors that know the object write the self-reference down — the +// object parse (`defineStack`, this stack) and the engine's registry (a +// metadata save, whose body is stored verbatim, reaches it unparsed) — so the +// walk, and every other reader of the target, sees an ordinary self-reference. +// +// What is pinned, through the REST data door as the signed-in caller: +// - `set_null` (the default the spec materializes) clears the child's parent; +// - `restrict` refuses with `DELETE_RESTRICTED` naming the tree's own object; +// - `cascade` deletes the subtree; +// - the metadata save door: an object saved through `PUT /meta/object/:name` +// with a referenceless tree clears the child too; +// - `os verify --delete-behaviors` derives a case for the field and proves it +// through both of its doors, instead of excluding it as "names no target"; +// - CONTROLS: a tree that writes its own name, a `lookup` and a +// `master_detail` answer exactly as they always did. +// +// And the other readers of the same target, at the record doors (measured on +// `origin/main` `275ad4229a` before, on this stack after): +// - the write-time reference check: a parent id that names no record is +// refused `400` with `reference_not_found` (it was `201`, accepted) — the +// one refusal this change adds at a data door; CONTROL: an existing parent +// is accepted; +// - `expand=parent` resolves the parent record (it was `404`, "declares no +// target object"); +// - a nested relation condition `{ parent: { title } }` filters through the +// parent (it was `400`, "declares no related object"). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { defineStack } from '@objectstack/spec'; + +import { bootStack, type VerifyStack } from './harness.js'; +import { runDeleteBehaviorProofs, type DeleteBehaviorReport } from './delete-behavior.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; +const RUN_TIMEOUT = 120_000; + +const treeNode = (name: string, parent: Record) => ({ + name, + label: name, + fields: { title: { type: 'text', label: 'Title' }, parent: { type: 'tree', label: 'Parent', ...parent } }, +}); + +const objects = [ + treeNode('rtl_default', {}), + treeNode('rtl_restrict', { deleteBehavior: 'restrict' }), + treeNode('rtl_cascade', { deleteBehavior: 'cascade' }), + // CONTROLS. + treeNode('rtl_written', { reference: 'rtl_written' }), + { name: 'rtl_account', label: 'Account', fields: { title: { type: 'text', label: 'Title' } } }, + { + name: 'rtl_contact', + label: 'Contact', + fields: { + title: { type: 'text', label: 'Title' }, + account: { type: 'lookup', label: 'Account', reference: 'rtl_account' }, + }, + }, + { + name: 'rtl_line', + label: 'Line', + fields: { + title: { type: 'text', label: 'Title' }, + account: { type: 'master_detail', label: 'Account', reference: 'rtl_account' }, + }, + }, +]; + +const config = defineStack({ + manifest: { id: 'com.example.referenceless-tree', namespace: 'rtl', version: '0.0.1', type: 'app', name: 'Referenceless Tree' }, + objects, +} as never); + +describe('[#23065] a referenceless tree field — delete behaviour at the public doors', () => { + let stack: VerifyStack; + let token: string; + let report: DeleteBehaviorReport; + + beforeAll(async () => { + stack = await bootStack(config as never); + token = await stack.signIn(); + report = await runDeleteBehaviorProofs(stack, token, config); + }, BOOT_TIMEOUT + RUN_TIMEOUT); + + afterAll(async () => { + await stack?.stop(); + }); + + const create = async (object: string, body: Record): Promise => { + const res = await stack.apiAs(token, 'POST', `/data/${object}`, body); + const text = await res.text(); + expect(res.status, `create ${object}: ${text}`).toBeLessThan(300); + const parsed = JSON.parse(text) as { id?: unknown; record?: { id?: unknown } }; + return String(parsed.id ?? parsed.record?.id); + }; + const read = async (object: string, id: string): Promise<{ status: number; record?: Record }> => { + const res = await stack.apiAs(token, 'GET', `/data/${object}/${id}`); + const body = JSON.parse(await res.text()) as { record?: Record }; + return { status: res.status, record: body.record }; + }; + const remove = async (object: string, id: string): Promise<{ status: number; body: any }> => { + const res = await stack.apiAs(token, 'DELETE', `/data/${object}/${id}`); + return { status: res.status, body: JSON.parse(await res.text()) }; + }; + + it('set_null (the materialized default): DELETE of the parent clears the child\'s parent', async () => { + const p = await create('rtl_default', { title: 'p' }); + const c = await create('rtl_default', { title: 'c', parent: p }); + + expect((await remove('rtl_default', p)).status).toBe(200); + + const child = await read('rtl_default', c); + expect(child.status).toBe(200); + expect(child.record?.parent).toBeNull(); + }); + + it('restrict: DELETE of the parent is refused with the envelope naming the tree\'s own object', async () => { + const p = await create('rtl_restrict', { title: 'p' }); + const c = await create('rtl_restrict', { title: 'c', parent: p }); + + const answer = await remove('rtl_restrict', p); + + expect(answer.status).toBe(409); + expect(answer.body).toMatchObject({ code: 'DELETE_RESTRICTED', object: 'rtl_restrict', dependentObject: 'rtl_restrict' }); + expect((await read('rtl_restrict', p)).status).toBe(200); + expect((await read('rtl_restrict', c)).record?.parent).toBe(p); + }); + + it('cascade: DELETE of the parent deletes the subtree', async () => { + const p = await create('rtl_cascade', { title: 'p' }); + const c = await create('rtl_cascade', { title: 'c', parent: p }); + const g = await create('rtl_cascade', { title: 'g', parent: c }); + const outside = await create('rtl_cascade', { title: 'outside' }); + + expect((await remove('rtl_cascade', p)).status).toBe(200); + + expect((await read('rtl_cascade', c)).status).toBe(404); + expect((await read('rtl_cascade', g)).status).toBe(404); + expect((await read('rtl_cascade', outside)).status).toBe(200); + }); + + it('the metadata save door: an object saved with a referenceless tree clears the child too', async () => { + // The save stores the authored body verbatim, so the registry receives the + // field without the parse's output: the door the parse alone did not cover. + const saved = await stack.apiAs(token, 'PUT', '/meta/object/rtl_saved', { + name: 'rtl_saved', + label: 'Saved', + sharingModel: 'public_read_write', + fields: { title: { type: 'text', label: 'Title' }, parent: { type: 'tree', label: 'Parent' } }, + }); + expect(saved.status, await saved.clone().text()).toBe(200); + + const p = await create('rtl_saved', { title: 'p' }); + const c = await create('rtl_saved', { title: 'c', parent: p }); + + expect((await remove('rtl_saved', p)).status).toBe(200); + expect((await read('rtl_saved', c)).record?.parent).toBeNull(); + }); + + it('os verify --delete-behaviors proves the referenceless fields through both doors', () => { + for (const [object, declared] of [ + ['rtl_default', 'set_null'], + ['rtl_restrict', 'restrict'], + ['rtl_cascade', 'cascade'], + ['rtl_written', 'set_null'], + ] as const) { + const r = report.results.find((x) => x.object === object && x.field === 'parent'); + expect(r, `${object}.parent: ${JSON.stringify(report.results.map((x) => [x.object, x.field, x.status]))}`).toBeDefined(); + expect(r!.status, `${object}.parent: ${JSON.stringify(r!.probes)}`).toBe('proven'); + expect(r!.declared).toBe(declared); + expect(r!.probes.map((probe) => probe.door)).toEqual(['rest', 'system']); + } + }); + + it('the write check: a parent id that names no record is refused 400 reference_not_found; an existing parent is accepted', async () => { + const refused = await stack.apiAs(token, 'POST', '/data/rtl_default', { title: 'orphan', parent: 'no_such_parent_id' }); + const body = JSON.parse(await refused.text()) as { code?: string; fields?: Array<{ field?: string; code?: string }> }; + expect(refused.status).toBe(400); + expect(body.code).toBe('VALIDATION_FAILED'); + expect(body.fields).toEqual( + expect.arrayContaining([expect.objectContaining({ field: 'parent', code: 'reference_not_found' })]), + ); + + // CONTROL: a parent that exists is accepted, and stored as written. + const p = await create('rtl_default', { title: 'p' }); + const c = await create('rtl_default', { title: 'c', parent: p }); + expect((await read('rtl_default', c)).record?.parent).toBe(p); + }); + + it('expand=parent resolves the parent record, and a nested relation condition filters through it', async () => { + const p = await create('rtl_default', { title: 'expand-parent' }); + const c = await create('rtl_default', { title: 'expand-child', parent: p }); + + const expanded = await stack.apiAs(token, 'GET', `/data/rtl_default/${c}?expand=parent`); + const record = (JSON.parse(await expanded.text()) as { record?: { parent?: { id?: unknown; title?: unknown } } }).record; + expect(expanded.status).toBe(200); + expect(record?.parent).toMatchObject({ id: p, title: 'expand-parent' }); + + const filter = encodeURIComponent(JSON.stringify({ parent: { title: 'expand-parent' } })); + const listed = await stack.apiAs(token, 'GET', `/data/rtl_default?filter=${filter}`); + const rows = (JSON.parse(await listed.text()) as { records?: Array<{ id?: unknown }> }).records; + expect(listed.status).toBe(200); + expect(rows?.map((r) => r.id)).toEqual([c]); + }); + + it('CONTROLS: a tree that writes its own name, a lookup and a master_detail answer as before', async () => { + const p = await create('rtl_written', { title: 'p' }); + const c = await create('rtl_written', { title: 'c', parent: p }); + expect((await remove('rtl_written', p)).status).toBe(200); + expect((await read('rtl_written', c)).record?.parent).toBeNull(); + + const a = await create('rtl_account', { title: 'a' }); + const contact = await create('rtl_contact', { title: 'contact', account: a }); + const line = await create('rtl_line', { title: 'line', account: a }); + expect((await remove('rtl_account', a)).status).toBe(200); + expect((await read('rtl_contact', contact)).record?.account).toBeNull(); + expect((await read('rtl_line', line)).status).toBe(404); + }); +});