Skip to content
Closed
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
4 changes: 2 additions & 2 deletions .changeset/29253-user-field-delete-behavior.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/objectql': minor
---
Expand All @@ -7,7 +7,7 @@
Clause-②: no (narrowing) — deleting a user that a `user` field declaring `restrict` (or `set_null` with `required: true`) holds is refused with `409 DELETE_RESTRICTED`, where it succeeded before; a declared `cascade` now deletes the rows holding the user, and a declared `set_null` now clears them. Nothing is widened.
Metadata-protocol: semantic

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: a `deleteBehavior` the spec already accepts on a `user` field is now enforced by the delete walk the way it is on a `lookup`. The refusal is the walk's own DELETE_RESTRICTED, so there is no stored value for a conversion to rewrite; an author who did not mean the declaration removes it or changes it. -->
<!-- adr-0087: registered user-field-delete-behavior-enforced -->

**BREAKING** for an app that declares `deleteBehavior` on a `user` field: the declared value now takes effect when the user is deleted.

Expand All @@ -17,6 +17,6 @@
- **Unchanged.** A `user` field that declares no `deleteBehavior` (the spec's parse adds none) stays out of the walk: deleting the user leaves the field as it was. `lookup`, `master_detail` and `tree` behave as before.
- **Which deletes.** Every user delete: better-auth deletes the user (admin remove-user, self delete-user) through the engine by id, and the generic data routes refuse a caller's own `sys_user` delete. A `restrict` refusal reaches the auth caller as `409 DELETE_RESTRICTED`, and the credential rows deleted before it are rolled back.

- **Rows stored before #9784.** Until then the spec's parse wrote `deleteBehavior: 'set_null'` on every field type, `user` included, so an object stored in that era can carry a `set_null` on a `user` field that no author wrote. That value now takes effect: the field is cleared when the user is deleted, and on a `required` field the delete is refused.
- **Rows stored before #9784.** Until then the spec's parse wrote `deleteBehavior: 'set_null'` on every field type, `user` included, so an object stored in that era can carry a `set_null` on a `user` field that no author wrote. That value now takes effect: the field is cleared when the user is deleted, and on a `required` field the delete is refused. No conversion rewrites it, because whether the value was meant is the author's to say: the semantic migration entry `user-field-delete-behavior-enforced` lists it for `objectstack migrate meta` and the upgrade guide. Save the object again without the key, or with the value you want.

**The fix.** If you declared `deleteBehavior` on a `user` field only as documentation, remove it or change it to what you want enforced. Nothing to do for a `user` field that declares no `deleteBehavior`.
14 changes: 14 additions & 0 deletions .changeset/29415-user-delete-behavior-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/spec': patch
---

docs(spec): register the `user`-field `deleteBehavior` enforcement as a semantic migration entry, and stop the spec saying a `user` field never joins the delete walk (#29415)

Clause-②: no
Metadata-protocol: none

The enforcement itself shipped in `@objectstack/objectql` (#29253): a `deleteBehavior` declared on a `user` field now takes effect when the user is deleted. This change records it and corrects the spec text around it. Nothing an author can write, and nothing the runtime does, moves.

- **Migration entry.** A new semantic entry, `user-field-delete-behavior-enforced`, so `objectstack migrate meta` and the protocol upgrade guide reach the change. It names the one stored population that needs a look: objects saved while the parse still wrote `deleteBehavior: 'set_null'` on every field type can carry a `set_null` on a `user` field nobody wrote. On a field that is not `required` it clears the field when the user is deleted and is fine as it is; on a `required` field it now refuses the user delete with `409 DELETE_RESTRICTED`, so remove it or declare the outcome you want.
- **Spec text.** The `deleteBehavior` docblocks in `FieldSchema`, the `user`-field `reference` refusal message, the `user-field-foreign-reference-refused` entry, the `referentialFieldClear` and `referentialCascadeDelete` hook-context descriptions and the `getCascadeDeleteObjects` contract docblock now say that a `user` field joins the delete walk when it declares a `deleteBehavior`, and stays out when it declares none.
- **Studio forms.** The field designer and the object form's field rows now show the `deleteBehavior` control on a `user` field, with all three outcomes, as they do on a `lookup`. A `user` field has no default, so the control writes a value only when you pick one.
2 changes: 1 addition & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
| **dispatch** | `{ mode: Enum<'record' \| 'per-row'>; index: integer; scope: Record<string, any> }` | optional | How this hook call relates to the caller's write (engine-produced) |
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. |
| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup, tree or user field that references a record being deleted, where a user field takes part only when it declares its deleteBehavior). Absent on every other write, including a user hand-clearing the same field; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. |
| **referentialCascadeDelete** | `boolean` | optional | Engine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead. |
| **transaction** | `any` | optional | Database transaction handle |
| **ql** | `any` | ✅ | ObjectQL Engine Reference |
Expand Down
6 changes: 5 additions & 1 deletion packages/lint/src/validate-predicate-path-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,11 @@ describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => {
// `data.type != 'joined'`, a single-quoted literal comparison.
// It is 65 today: #28255's `field :: percentDisplay` row is gated on
// `data.type == 'percent'`, a single-quoted literal comparison.
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(65);
// It is 63 today: #29415 opened both `deleteBehavior` gates (field form and
// object form) to `user` and respelled them `data.type in ['lookup','user']`,
// an `in`-list outside this rule, so two `==` literal comparisons left the
// census.
expect(comparisons, 'no shipped predicate carries an `==`/`!=` literal comparison').toBe(63);

const rhsFindings = validatePredicatePathRefs(corrupted)
.filter((f) => f.rule === PREDICATE_RHS_PATH_SHAPED);
Expand Down
8 changes: 4 additions & 4 deletions packages/spec/src/contracts/objectql-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,10 @@ export interface IObjectQLEngine extends IDataEngine, QueryEvaluationGate {
/**
* The objects a delete of a record of `objectName` can cascade into, by
* name: every object holding a relation into it whose resolved delete
* behaviour is `cascade` (a `master_detail` by default, a `lookup` or
* `tree` that declares `deleteBehavior: 'cascade'`), and transitively the
* objects that cascade into those, each once, in the order the delete's
* own walk first reaches them. `objectName` itself is named only when a
* behaviour is `cascade` (a `master_detail` by default, a `lookup`, `tree`
* or `user` field that declares `deleteBehavior: 'cascade'`), and
* transitively the objects that cascade into those, each once, in the
* order the delete's own walk first reaches them. `objectName` itself is named only when a
* cascading relation leads back to it. A `restrict` or `set_null`
* relation removes nothing and is not named. `undefined` when the engine
* does not hold `objectName`.
Expand Down
10 changes: 9 additions & 1 deletion packages/spec/src/data/field.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,18 @@ export const fieldForm = defineForm({
// their translation exactly as they are today. The two branches are
// mutually exclusive, so no author ever sees both spellings at once.
//
// `user` shares the `lookup` branch (#29415): since #29253 a declared
// `deleteBehavior` on a `user` field takes effect when the user is
// deleted, with all three outcomes legal, so the designer offers the
// key it honours. A `user` field has NO default — declaring nothing
// keeps it out of the delete walk — so the control writes a value only
// when the author picks one; the enum's advertised `default` is a
// `lookup` fact the select does not prefill.
//
// `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', visibleWhen: "data.type in ['lookup','user']", 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
26 changes: 17 additions & 9 deletions packages/spec/src/data/field.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1819,9 +1819,11 @@ export const FieldSchema = lazySchema(() => {
* it per the #9689 idempotent-materialization ruling). On every other type
* 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
* `reference`, and on a `user` field that AUTHORS one (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; a `user` field's target is `sys_user`, fixed by the
* type, and #29253 made its authored value take effect) — 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
Expand Down Expand Up @@ -2734,8 +2736,10 @@ export const FieldSchema = lazySchema(() => {
// No ADR-0087 conversion carries it (the D3 entry
// `user-field-foreign-reference-refused` says why): rewriting the field to a
// `lookup` would add it to the referential delete walk with
// `deleteBehavior: 'set_null'`, which a `user` field never joined, and
// `deleteBehavior` has no value meaning "leave it", so the author decides.
// `deleteBehavior: 'set_null'`, while a `user` field that declares no
// `deleteBehavior` stays out of that walk (#29253: one that declares a value
// joins it), and `deleteBehavior` has no value meaning "leave it", so the
// author decides.
if (
field.type === 'user'
&& field.reference !== undefined
Expand All @@ -2750,7 +2754,8 @@ export const FieldSchema = lazySchema(() => {
`\`reference: '${field.reference}'\`. To link records of \`${field.reference}\`, declare a ` +
`\`lookup\`: \`{ type: 'lookup', reference: '${field.reference}' }\` (\`Field.lookup('${field.reference}', …)\`), ` +
"and choose its `deleteBehavior` (a `lookup` defaults to `'set_null'` when the target record is " +
'deleted; a `user` field never did). If a person was meant, drop `reference`.',
'deleted; a `user` field has no default and keeps the id unless it declares one). If a person was ' +
'meant, drop `reference`.',
});
}

Expand Down Expand Up @@ -3298,9 +3303,12 @@ export const FieldSchema = lazySchema(() => {
// stay legal. `tree` (hierarchical reference) keeps materializing with
// `lookup`: it is in the relational family, where the key states delete
// 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.
// stored identically to `lookup` but takes the non-reference side: it
// joins the delete walk only when its author DECLARES a `deleteBehavior`
// (#29253, `statesDeleteSemantics`), so a materialized value here would be
// a delete outcome nobody chose. A bare `user` field therefore parses with
// no key and keeps the deleted user's id; an authored value round-trips
// and takes effect.
if (field.deleteBehavior === undefined && (field.type === 'lookup' || field.type === 'tree')) {
patch.deleteBehavior = 'set_null';
}
Expand Down
16 changes: 16 additions & 0 deletions packages/spec/src/data/form-delete-behavior-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,22 @@ describe('`deleteBehavior` is never offered a value the schema refuses', () => {
expect(visible.map((d) => d.path)).toHaveLength(1);
});

// #29415: since #29253 a declared `deleteBehavior` on a `user` field takes
// effect when the user is deleted, so the designer must offer it there —
// all three outcomes, from the same single control a `lookup` author gets.
it('offers exactly one control to a user author, with all three values', () => {
const visible = decls.filter((d) => isVisibleForType(d.spec, 'user'));
expect(visible.map((d) => d.path)).toHaveLength(1);
const offered = declaredOptionValues(visible[0].spec) ?? (derivedDeleteBehaviorNode().enum as string[]);
expect(offered).toEqual(['set_null', 'cascade', 'restrict']);
});

// The control stays confined to the types whose `deleteBehavior` states
// delete semantics a Studio author can set; a `text` field is the control.
it('offers no control to a text author', () => {
expect(decls.filter((d) => isVisibleForType(d.spec, 'text'))).toHaveLength(0);
});

// THE DEFECT. A master_detail author must never see "Set null".
it('does not offer set_null to a master_detail author', () => {
const visible = decls.filter((d) => isVisibleForType(d.spec, 'master_detail'));
Expand Down
25 changes: 14 additions & 11 deletions packages/spec/src/data/hook.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1401,11 +1401,12 @@ export const HookContextSchema = lazySchema(() => z.object({
/**
* Referential-Cleanup Marker
* `true` exactly when THIS write is the engine's own reference cleanup — the
* UPDATE `cascadeDeleteRelations` issues against a row that HOLDS a lookup
* whose `deleteBehavior` resolves to `set_null`, while the record it
* references is being deleted (clearing the slot, or on a `multiple: true`
* lookup removing the deleted member). Absent on every other dispatch; read
* it as `ctx.referentialFieldClear === true`.
* UPDATE `cascadeDeleteRelations` issues against a row that HOLDS a
* reference field whose `deleteBehavior` resolves to `set_null` (a `lookup`
* or `tree` by default, or a `user` field that declares it, #29253), while
* the record it references is being deleted (clearing the slot, or on a
* `multiple: true` field removing the deleted member). Absent on every other
* dispatch; read it as `ctx.referentialFieldClear === true`.
*
* ## Why a declared key (commit 34ce8e7db)
*
Expand Down Expand Up @@ -1458,8 +1459,9 @@ export const HookContextSchema = lazySchema(() => z.object({
referentialFieldClear: z.boolean().optional().describe(
"Engine-produced marker: true exactly when this write is the engine's own referential cleanup "
+ "(the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member "
+ 'from — a lookup that references a record being deleted). Absent on every other write, '
+ 'including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. '
+ 'from — a lookup, tree or user field that references a record being deleted, where a user field '
+ 'takes part only when it declares its deleteBehavior). Absent on every other write, including a '
+ 'user hand-clearing the same field; read as `ctx.referentialFieldClear === true`. '
+ 'The declared projection of the operation-private `__referentialFieldClear`, carried across '
+ 'the sandbox boundary by contract; server-derived and never client-supplied.',
),
Expand All @@ -1470,10 +1472,11 @@ export const HookContextSchema = lazySchema(() => z.object({
* child DELETE `cascadeDeleteRelations` performs for each row that
* references a record being deleted through a relation whose
* `deleteBehavior` resolves to `cascade` (a `master_detail` unless it
* declares `restrict`; a `lookup` or `tree` that declares `cascade`), at any
* depth: a grandchild the child's own cascade removes is marked too. Present
* in both phases of that delete (`beforeDelete`, `afterDelete`). Absent on
* every other dispatch; read it as `ctx.referentialCascadeDelete === true`.
* declares `restrict`; a `lookup`, `tree` or `user` field that declares
* `cascade`, the `user` one since #29253), at any depth: a grandchild the
* child's own cascade removes is marked too. Present in both phases of that
* delete (`beforeDelete`, `afterDelete`). Absent on every other dispatch;
* read it as `ctx.referentialCascadeDelete === true`.
*
* The delete twin of {@link referentialFieldClear}. That key marks the
* `set_null` cleanup UPDATE; this one marks the `cascade` child DELETE. A
Expand Down
Loading
Loading