diff --git a/.changeset/28001-cascade-delete-objects-beside-item.md b/.changeset/28001-cascade-delete-objects-beside-item.md new file mode 100644 index 00000000000..c697ff4980b --- /dev/null +++ b/.changeset/28001-cascade-delete-objects-beside-item.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': patch +'@objectstack/metadata-protocol': patch +'@objectstack/rest': patch +--- + +feat: the object read names the objects a delete can cascade into, beside the item + +Clause-②: yes (widening) +Metadata-protocol: added +Skills-docs: none — `cascadeDeleteObjects` is a served-only key beside the object read with no authorable spelling; no file under `skills/**` teaches the object read's envelope keys (a `git grep` of `skills/` for `cascadeDeleteObjects` and `disabledActions` names nothing), and every taught `deleteBehavior` use stays true. + +- **Served beside the object** (`@objectstack/metadata-protocol`, `@objectstack/rest`). `GET /api/v1/meta/object/:name` serves `cascadeDeleteObjects` beside `item`: the names of the objects that deleting a record of this object can also delete. That is every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once. A `restrict` or `set_null` relation removes nothing and is not named. The default cached door carries it too, and the list is folded into that door's ETag, so an object registered later that cascades into this one changes the ETag. The key is absent where no engine answered, on a draft read, on the list read and on every type other than `object`; `[]` means nothing cascades. `item` is unchanged. A delete confirmation can now name what else a delete removes without keeping its own copy of the rule. +- **The fact it serves** (`@objectstack/objectql`). `ObjectQL.getCascadeDeleteObjects(object)` answers that list from the delete walk's own relation scan, which the delete and this read now share. It reads the registry and no row, so it never says which records or how many. It returns `undefined` for an object the registry does not hold. +- **The declaration** (`@objectstack/spec`). `GetMetaItemResponseSchema` and `GetMetaItemCachedResponseSchema` gain the optional `cascadeDeleteObjects` (an array of object names), and `IObjectQLEngine` gains the optional member `getCascadeDeleteObjects(objectName)`, which the metadata read calls through the kernel's service table. Nothing about it is authorable. diff --git a/content/docs/references/api/app-bundle.mdx b/content/docs/references/api/app-bundle.mdx index 0ddf82b90e3..5e0d2ab0f3a 100644 --- a/content/docs/references/api/app-bundle.mdx +++ b/content/docs/references/api/app-bundle.mdx @@ -99,6 +99,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.objects[number]` @@ -122,6 +123,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.views[number]` @@ -145,6 +147,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.pages[number]` @@ -168,6 +171,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `AppMetadataBundleResponse.dashboards[number]` @@ -191,6 +195,7 @@ const result = AppMetadataBundleResponseSchema.parse(data); | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3c6ed44e69f..b7ec53c9bf1 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1335,6 +1335,7 @@ This schema accepts one of the following structures: | **version** | `string` | optional | Metadata version identifier | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on the flow in `data`, read live on every call and never part of `etag` (see `FlowRegistrationSchema`). Present only for a `flow` read answered by a process that runs an automation service; a read that carries it is never answered `notModified`. Absent otherwise, and absence means no engine judged it, never `registered`. | | **disabledActions** | `string[]` | optional | The names in the `actions` of the object in `data` that this deployment's activation ledger switches off (a call to any of them is refused 409 `ACTION_DISABLED`), read live on every call and never part of `etag`. Present only for an `object` read answered by a process whose data engine has an activation ledger attached, `[]` when none is switched off; this method never answers `notModified` for a read that carries it (the REST door folds the served list into the ETag it checks instead, so its 304 comes back only while the switched-off set is unchanged). Absent otherwise, and absence means no ledger answered, never that nothing is switched off. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of the object in `data` can also delete (the item read's `cascadeDeleteObjects`), read live on every call and never part of `etag`. Present only for an `object` read answered by a process whose data engine answers it, `[]` when nothing cascades; this method never answers `notModified` for a read that carries it (the REST door folds the served list into the ETag it checks instead). Absent otherwise, and absence means no engine answered, never that nothing cascades. | ### Nested Shape: `GetMetaItemCachedResponse.etag` @@ -1462,6 +1463,7 @@ This schema accepts one of the following structures: | **version** | `string \| null` | optional | ADR-0008 version token of the STORED row a save to this item compares against, at this read's scope (the environment's partition and `?package=`) and lifecycle (`?state=draft` reads the draft row, the plain read the active row). The same keyed token a save receipt's `version` serves for that row: send it back as the `If-Match` request header on `PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a concurrent edit is refused 409 `METADATA_CONFLICT` instead of overwritten. `null` when no stored row is there — an item served from code or a package artifact, or from a row in another scope (a package-less row a package read falls back to) — so the next save creates one: pin it with `If-None-Match: *`. A `null` beside `editable: false` is no such invitation: that item takes no save at this address, so no pin is prescribed for it. ABSENT on the cached published-value branch and on a `?preview=draft` read, which publish no version: absence means "not published here", never "no row". Opaque — echo it verbatim, never parse it. | | **registration** | `{ state: 'registered' } \| { state: 'refused'; reason: object } \| { state: 'unregistered' }` | optional | The automation engine's verdict on this flow (see `FlowRegistrationSchema`): `registered`, or `refused` with the engine's `reason` verbatim, or `unregistered`. Present only on a `flow` read answered by a process that runs an automation service; absent otherwise, and absence means no engine judged it, never `registered`. Unlike the protection keys it is published by the cached branch too, read live and never part of the ETag, so a cached read that carries it is never answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is the answering process's engine. Beside `item`, never inside it: the item's `status` stays what its author declared. | | **disabledActions** | `string[]` | optional | The names in `item.actions` that this deployment's activation ledger switches off (an administrator turned them off under Setup › Packaged automation): a call to any of them is refused 409 `ACTION_DISABLED`. Present only on an `object` read answered by a process whose data engine has an activation ledger attached, and then `[]` when none of this object's actions is switched off. Absent otherwise, and absence means no ledger answered, never that nothing is switched off. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag, so a cached read revalidates to 304 only while the switched-off set is unchanged; it is absent on a draft read. The ledger is the answering process's engine's. Beside `item`, never inside it: `item.actions` is served as declared, a switched-off action included. | +| **cascadeDeleteObjects** | `string[]` | optional | The objects, by name, that deleting a record of this object can also delete: every object whose relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares `deleteBehavior: 'cascade'`), and the objects that cascade into those in turn, each once, in the order the delete reaches them. This object itself is named only when a cascading relation leads back to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the delete runs, and object-level: it never says which records or how many. Present only on an `object` read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a draft read. Beside `item`, never inside it: it is derived from other objects' definitions and is not authorable. | | **draftChanges** | `{ path: string[]; by: Enum<'copilot' \| 'mcp' \| 'studio' \| 'api'>; actor: string \| null; client?: object; … }[]` | optional | Per-key provenance of the pending draft: which authoring surface last changed each key path of `item`, under which actor, and when. The draft save records it: each draft save is compared with the body that save replaced (the previous draft, or the stored published row for the draft's first save; a draft of an item with no stored published row is recorded as `[]`, the whole body). A `migrate-stored` rewrite of a draft row records nothing, since it is not authoring. The entries are kept from the first recorded save until the draft is published or discarded, which remove the draft row and the entries with it; and no two entries share a path: a save replaces every entry at or under each path it changes, so the last write to change a key is the entry whose `path` is the longest prefix of that key's path, and entries under a key's path are changes inside it. The entries name which writes changed what; they are not a list of every difference from the published body (compare the two bodies for that). Served only on a `?state=draft` read that answers a stored draft row. ABSENT on the active read, the cached branch and a `?preview=draft` read, and for a draft row whose saves recorded none: absence means "not recorded", never "no changes". | ### Nested Shape: `GetMetaItemResponse.sortability` diff --git a/packages/metadata-protocol/src/protocol.object-cascade-delete-objects.test.ts b/packages/metadata-protocol/src/protocol.object-cascade-delete-objects.test.ts new file mode 100644 index 00000000000..e1d0be08cfd --- /dev/null +++ b/packages/metadata-protocol/src/protocol.object-cascade-delete-objects.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28001] The `object` item read serves, beside the item, the objects a + * delete of one of its records can cascade into: `cascadeDeleteObjects` on + * both doors. It takes `disabledActions`' (#27842) four positions: + * + * - read through the kernel's service table by name (`objectql`), for the + * optional member the engine declares on `IObjectQLEngine` + * (`getCascadeDeleteObjects`) — this package imports no engine and holds no + * copy of which relations cascade; + * - absent where no engine answered (no engine, an engine without the + * member, or one that does not hold the object), never `[]` there; + * - `[]` where the engine answered that nothing cascades; + * - on the cached default door too, never in its ETag, and a read that + * carries it is never answered 304 (the REST door folds it instead). + * + * `item` is served as stored: the list is beside it, never in it. + * + * The engine double answers the way `ObjectQL` does (pinned on the real + * engine, with the real protocol, in `@objectstack/objectql`'s + * `engine-cascade-delete-objects.test.ts`). + */ +import { describe, expect, it, vi } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface StoredRow { + id: string; + type: string; + name: string; + package_id: string | null; + state: string; + metadata: string; +} + +const storedRow = (type: string, name: string, body: unknown, extra: Partial = {}): StoredRow => ({ + id: `r_${type}_${name}_${extra.state ?? 'active'}`, + type, + name, + package_id: null, + state: 'active', + metadata: JSON.stringify(body), + ...extra, +}); + +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + +const TASK = { + name: 'showcase_task', + label: 'Task', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + actions: [ + { name: 'showcase_mark_done', label: 'Mark Done', type: 'script', locations: ['record_header'] }, + { name: 'showcase_snooze', label: 'Snooze', type: 'script', locations: ['record_header'] }, + ], +}; +const PROJECT = { + name: 'showcase_project', + label: 'Project', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, + actions: [{ name: 'showcase_archive', label: 'Archive', type: 'script' }], +}; +const NOTE = { name: 'showcase_note', label: 'Note', fields: { body: { name: 'body', type: 'text', label: 'Body' } } }; +const ROWS = [storedRow('object', TASK.name, TASK), storedRow('object', PROJECT.name, PROJECT), storedRow('object', NOTE.name, NOTE)]; + +/** + * The store double: `find` / `findOne` over `sys_metadata` rows, and a + * registry that holds nothing of its own. ⛔ No write verb — the reads under + * test issue none. + */ +function harness(rows: StoredRow[], services: Map = new Map()) { + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return rows.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), + ); + }; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + registry: { + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + getObject: () => undefined, + registerItem: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }, + }; + return new ObjectStackProtocolImplementation(engine, () => services as Map); +} + +/** The engine's answer: the cascade per object name, `undefined` for an object it does not hold. */ +function engineDouble(cascades: Record) { + return { + getCascadeDeleteObjects: vi.fn((objectName: string): string[] | undefined => + Object.prototype.hasOwnProperty.call(cascades, objectName) ? [...cascades[objectName]!] : undefined), + }; +} + +const CASCADES = { [TASK.name]: ['showcase_subtask', 'showcase_checklist_item'], [PROJECT.name]: [] }; +const withEngine = (cascades: Record = CASCADES) => + new Map([['objectql', engineDouble(cascades)]]); + +describe('[#28001] the item read: `cascadeDeleteObjects` beside `item`, the engine\'s answer or nothing', () => { + it('names what a delete of this object cascades into, and serves `item` as stored', async () => { + const plain: any = await harness(ROWS).getMetaItem({ type: 'object', name: TASK.name }); + const judged: any = await harness(ROWS, withEngine()).getMetaItem({ type: 'object', name: TASK.name }); + + expect(judged.cascadeDeleteObjects).toEqual(['showcase_subtask', 'showcase_checklist_item']); + expect(JSON.stringify(judged.item)).toBe(JSON.stringify(plain.item)); + expect(judged.item).not.toHaveProperty('cascadeDeleteObjects'); + expect(judged).toHaveProperty('lock'); + }); + + it('`[]` when the engine answered that nothing cascades', async () => { + expect((await harness(ROWS, withEngine()).getMetaItem({ type: 'object', name: PROJECT.name }) as any).cascadeDeleteObjects) + .toEqual([]); + }); + + it('control: no key where no engine answered — no engine, an engine without the member, an object it does not hold', async () => { + const cases: Array<[Map, string]> = [ + [new Map(), TASK.name], + [new Map([['objectql', { find: async () => [] }]]), TASK.name], + [withEngine(), NOTE.name], + ]; + for (const [services, name] of cases) { + const read: any = await harness(ROWS, services).getMetaItem({ type: 'object', name }); + expect(read.item.name).toBe(name); + expect(read).not.toHaveProperty('cascadeDeleteObjects'); + } + }); + + it('the plural spelling of the type is folded first and answers the same', async () => { + const read: any = await harness(ROWS, withEngine()).getMetaItem({ type: 'objects', name: TASK.name }); + expect(read.cascadeDeleteObjects).toEqual(['showcase_subtask', 'showcase_checklist_item']); + }); + + it('a type other than `object` reads no key, and the engine is never asked', async () => { + const services = withEngine(); + const read: any = await harness( + [...ROWS, storedRow('view', 'task_board', { name: 'task_board', label: 'Board' })], services, + ).getMetaItem({ type: 'view', name: 'task_board' }); + + expect(read).not.toHaveProperty('cascadeDeleteObjects'); + expect((services.get('objectql') as ReturnType).getCascadeDeleteObjects).not.toHaveBeenCalled(); + }); + + it('the execution face carries none', async () => { + const read: any = await harness(ROWS, withEngine()).getMetaItemForExecution({ type: 'object', name: TASK.name }); + expect(read).not.toHaveProperty('cascadeDeleteObjects'); + }); + + it('the list carries none: the item read is the one the delete confirmations read', async () => { + const list: any = await harness(ROWS, withEngine()).getMetaItems({ type: 'object' }); + expect(list.items.length).toBe(3); + expect(list).not.toHaveProperty('cascadeDeleteObjects'); + for (const item of list.items) expect(item).not.toHaveProperty('cascadeDeleteObjects'); + }); +}); + +describe('[#28001] the cached read — the default door — carries it beside `data`, never in the ETag', () => { + it('serves the engine\'s answer beside the document, with an ETag over the document alone', async () => { + const judged: any = await harness(ROWS, withEngine()).getMetaItemCached({ type: 'object', name: TASK.name }); + const unjudged: any = await harness(ROWS).getMetaItemCached({ type: 'object', name: TASK.name }); + + expect(judged.cascadeDeleteObjects).toEqual(['showcase_subtask', 'showcase_checklist_item']); + expect(judged.data).not.toHaveProperty('cascadeDeleteObjects'); + expect(judged.etag).toEqual(unjudged.etag); + expect(unjudged).not.toHaveProperty('cascadeDeleteObjects'); + }); + + it('a read that carries the key is never answered 304; control: one where no engine answered still is', async () => { + const first: any = await harness(ROWS, withEngine()).getMetaItemCached({ type: 'object', name: TASK.name }); + const revalidate = { type: 'object', name: TASK.name, cacheRequest: { ifNoneMatch: `"${first.etag.value}"` } }; + + const judged: any = await harness(ROWS, withEngine()).getMetaItemCached(revalidate); + expect(judged.notModified).toBe(false); + expect(judged.cascadeDeleteObjects).toEqual(['showcase_subtask', 'showcase_checklist_item']); + + const unjudged: any = await harness(ROWS, new Map()).getMetaItemCached(revalidate); + expect(unjudged.notModified).toBe(true); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 42c7e95ceab..76b8a9b906e 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -147,7 +147,7 @@ import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSp // [#13331] The cluster fan-out transport type only — the protocol never // depends on `@objectstack/service-cluster`; a bridge plugin there hands the // live transport in through `attachMetadataMutationPubSub`. -import type { IFieldFileService, IPubSub } from '@objectstack/spec/contracts'; +import type { IFieldFileService, IObjectQLEngine, IPubSub } from '@objectstack/spec/contracts'; import { FIELD_FILE_SERVICE } from '@objectstack/spec/contracts'; import { applyConversionsToFlow, type ConversionNotice, type ConversionTodoNotice } from '@objectstack/spec'; import { type FormView, type I18nLabel, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; @@ -6935,6 +6935,34 @@ export class ObjectStackProtocolImplementation implements return resolveDisabledActionsReader.call(host, type); } + /** + * [#28001] The `cascadeDeleteObjects` beside an `object` item read's + * envelope (`GetMetaItemResponseSchema` in `@objectstack/spec`): the + * objects a delete of one of `name`'s records can cascade into, as the data + * engine answers it from the rule its delete walk runs + * (`IObjectQLEngine.getCascadeDeleteObjects`). + * + * The seam {@link resolveDisabledActionsReader} uses, for its reasons: the + * kernel's service table, read by name per call (`objectql`), for an + * optional member the engine declares on its contract. This package never + * imports the engine, and it holds no copy of which relations cascade. + * + * `undefined` — so the read serves NO key — for every other type, where no + * reachable engine declares the member, and where the engine does not + * hold the object. ⛔ Never `[]` in those cases: `[]` is the engine's own + * "nothing cascades". + */ + private resolveCascadeDeleteObjects(type: string, name: string): string[] | undefined { + if (canonicalMetaType(type) !== 'object') return undefined; + const engine = this.getServicesRegistry?.().get('objectql') as + | Pick + | undefined; + const read = engine?.getCascadeDeleteObjects; + if (typeof read !== 'function') return undefined; + const answer = read.call(engine, name); + return Array.isArray(answer) ? [...answer] : undefined; + } + /** * [#27842] The list's `disabledActions`: the reader's answer per listed * object, keyed by its `name` — or nothing, where @@ -11401,6 +11429,11 @@ export class ObjectStackProtocolImplementation implements const readDisabledActions = !execution && decorated !== undefined && decorated !== null ? this.resolveDisabledActionsReader(request.type) : undefined; + // [#28001] …and the objects a delete of one of its records can cascade + // into ({@link resolveCascadeDeleteObjects}), on the same terms. + const cascadeDeleteObjects = !execution && decorated !== undefined && decorated !== null + ? this.resolveCascadeDeleteObjects(request.type, request.name) + : undefined; return { type: request.type, name: request.name, @@ -11431,6 +11464,7 @@ export class ObjectStackProtocolImplementation implements }), ...(readRegistration ? { registration: readRegistration(request.name) } : {}), ...(readDisabledActions ? { disabledActions: readDisabledActions(decorated) } : {}), + ...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}), }; } @@ -14560,7 +14594,7 @@ export class ObjectStackProtocolImplementation implements * environment → code read order (ADR-0131 D6) — the whole reason this * method delegates rather than re-reading. */ - async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { + async getMetaItemCached(request: { type: string, name: string, cacheRequest?: MetadataCacheRequest, locale?: string }): Promise { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. The ETag // and the cache entry are keyed by type, so two spellings would cache // the same item twice and invalidate only one of them. @@ -14583,6 +14617,9 @@ export class ObjectStackProtocolImplementation implements // ledger switches off, on exactly those terms: live, beside `data`, // never hashed (see the 304 note below). const disabledActions = (result as { disabledActions?: string[] } | undefined)?.disabledActions; + // [#28001] …and the objects a delete of one of its records can + // cascade into, on the same terms. + const cascadeDeleteObjects = (result as { cascadeDeleteObjects?: string[] } | undefined)?.cascadeDeleteObjects; if (!item) { // [#5532] Structured: 404 + the catalog's `RESOURCE_NOT_FOUND`. @@ -14649,7 +14686,12 @@ export class ObjectStackProtocolImplementation implements // [#27842] The same holds for `disabledActions`: an administrator's // switch moves no byte of the object document, so a 304 would keep // the caller on the switched-off list it was served before. - if (request.cacheRequest?.ifNoneMatch && registration === undefined && disabledActions === undefined) { + // [#28001] So does `cascadeDeleteObjects`: an object registered + // later that cascades into this one moves no byte of it either. + if ( + request.cacheRequest?.ifNoneMatch + && registration === undefined && disabledActions === undefined && cascadeDeleteObjects === undefined + ) { const clientEtag = request.cacheRequest.ifNoneMatch.replace(/^"(.*)"$/, '$1').replace(/^W\/"(.*)"$/, '$1'); if (clientEtag === hash) { // Return 304 Not Modified @@ -14665,6 +14707,7 @@ export class ObjectStackProtocolImplementation implements data: item, ...(registration !== undefined ? { registration } : {}), ...(disabledActions !== undefined ? { disabledActions } : {}), + ...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}), etag, lastModified: new Date().toISOString(), cacheControl: { diff --git a/packages/objectql/src/engine-cascade-delete-objects.test.ts b/packages/objectql/src/engine-cascade-delete-objects.test.ts new file mode 100644 index 00000000000..36b3cc88a00 --- /dev/null +++ b/packages/objectql/src/engine-cascade-delete-objects.test.ts @@ -0,0 +1,238 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28001] `ObjectQL.getCascadeDeleteObjects` names the objects a delete of a + * record can cascade into, and the object read serves that list beside the + * object as `cascadeDeleteObjects`, so a delete confirmation can name them. + * + * Ruling B on #28001: the client gets the list from the server, never from a + * second copy of the engine rule. So the pins below hold three things: + * + * - the list is the delete walk's own rule: a `master_detail` child by + * default, a lookup only when it declares `deleteBehavior: 'cascade'`, the + * grandchildren a child's own cascade reaches, and never a `restrict` or + * `set_null` relation, which remove nothing; + * - it agrees with the delete: every object whose rows a real delete removed + * is named, and nothing the delete left alone; + * - the real protocol serves it beside the item (both doors), read through + * the kernel's service table as a host composes it, and `item` is untouched. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject } from '@objectstack/metadata-core'; +import { ObjectQL } from './engine.js'; + +type Row = Record; + +function makeDriver() { + const stores = new Map>(); + const storeFor = (o: string): Map => { + let s = stores.get(o); + if (!s) { s = new Map(); stores.set(o, s); } + return s; + }; + let nextId = 0; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k === '$and' && Array.isArray(v)) { + if (!v.every((w) => matches(row, w))) return false; + continue; + } + if (k === '$or' && Array.isArray(v)) { + if (!v.some((w) => matches(row, w))) return false; + continue; + } + if (k.startsWith('$')) continue; + // The delete's collection probes the records of one depth together. + if (v && typeof v === 'object' && Array.isArray((v as Row).$in)) { + if (!((v as Row).$in as unknown[]).some((x) => String(x) === String(row[k]))) return false; + continue; + } + const exp = v && typeof v === 'object' && '$eq' in (v as Row) ? (v as Row).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'primary', version: '0.0.0', supports: {}, + rowIds: (o: string): string[] => Array.from(storeFor(o).keys()).sort(), + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; }, + async syncSchema() {}, + async find(o: string, ast: { where?: unknown; limit?: unknown } | undefined) { + const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows; + }, + async findOne(o: string, ast: { where?: unknown } | undefined) { + for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(o: string, data: Row) { + nextId += 1; + const id = (data.id as string | undefined) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(o).set(id, row); + return row; + }, + async update(o: string, id: string, data: Row) { + const cur = storeFor(o).get(String(id)); + if (!cur) throw new Error(`not found ${o}/${id}`); + const up = { ...cur, ...data, id }; + storeFor(o).set(String(id), up); + return up; + }, + async upsert(o: string, data: Row) { + const id = data.id as string | undefined; + if (id && storeFor(o).has(id)) return this.update(o, id, data); + return this.create(o, data); + }, + async delete(o: string, id: string) { return storeFor(o).delete(String(id)); }, + async count(o: string, ast: { where?: unknown } | undefined) { return (await this.find(o, ast)).length; }, + async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r))); }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + return driver; +} + +const id = { name: 'id', type: 'text' as const, primaryKey: true }; +const name = { name: 'name', type: 'text' as const }; + +/** + * `cd_parent` ← `cd_child` (master_detail, the default cascade) ← `cd_grandchild` + * (a lookup that declares `cascade`), beside the relations that remove nothing: + * a `restrict` and a `set_null` sibling on the parent, and a default (`set_null`) + * lookup on the child. + */ +const OBJECTS = [ + { name: 'cd_parent', label: 'Parent', fields: { id, name } }, + { name: 'cd_child', label: 'Child', fields: { id, name, parent: { name: 'parent', type: 'master_detail', reference: 'cd_parent', required: true } } }, + { name: 'cd_grandchild', label: 'Grandchild', fields: { id, name, child: { name: 'child', type: 'lookup', reference: 'cd_child', deleteBehavior: 'cascade' } } }, + { name: 'cd_pin', label: 'Pin', fields: { id, name, parent: { name: 'parent', type: 'lookup', reference: 'cd_parent', deleteBehavior: 'restrict' } } }, + { name: 'cd_tag', label: 'Tag', fields: { id, name, parent: { name: 'parent', type: 'lookup', reference: 'cd_parent', deleteBehavior: 'set_null' } } }, + { name: 'cd_note', label: 'Note', fields: { id, name, child: { name: 'child', type: 'lookup', reference: 'cd_child' } } }, + // An object nothing points at. + { name: 'cd_loner', label: 'Loner', fields: { id, name } }, + // A tree whose own children cascade: the cycle terminates and names the object itself. + { name: 'cd_folder', label: 'Folder', fields: { id, name, parent_folder: { name: 'parent_folder', type: 'tree', reference: 'cd_folder', deleteBehavior: 'cascade' } } }, +]; + +const SYS_METADATA = { + name: 'sys_metadata', + label: 'System Metadata', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + type: { name: 'type', label: 'Type', type: 'text' as const, required: true }, + name: { name: 'name', label: 'Name', type: 'text' as const, required: true }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + package_id: { name: 'package_id', label: 'Package', type: 'text' as const }, + metadata: { name: 'metadata', label: 'Body', type: 'textarea' as const }, + checksum: { name: 'checksum', label: 'Checksum', type: 'text' as const, maxLength: 71 }, + state: { name: 'state', label: 'State', type: 'text' as const }, + version: { name: 'version', label: 'Version', type: 'number' as const }, + created_at: { name: 'created_at', label: 'Created', type: 'datetime' as const }, + updated_at: { name: 'updated_at', label: 'Updated', type: 'datetime' as const }, + }, +}; + +async function makeEngine() { + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const engine = new ObjectQL({ logger } as any); + const driver = makeDriver(); + engine.registerDriver(driver, true); + await engine.init(); + for (const o of OBJECTS) engine.registry.registerObject(o as any, '__test__'); + return { engine, driver }; +} + +async function makeHost() { + const { engine, driver } = await makeEngine(); + engine.registry.registerObject(SYS_METADATA as any); + for (const o of [SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) engine.registry.registerObject(o as any); + // The kernel's service table as `ObjectQLPlugin` fills it: the engine under `objectql`. + const services = new Map([['objectql', engine], ['data', engine]]); + return { engine, driver, protocol: new ObjectStackProtocolImplementation(engine, () => services as Map) }; +} + +const SYSTEM = { context: { isSystem: true } } as any; + +describe('[#28001] getCascadeDeleteObjects: the objects a delete can cascade into, by the delete\'s own rule', () => { + it('names the master_detail child and the grandchild its lookup cascades into, and not the restrict / set_null siblings', async () => { + const { engine } = await makeEngine(); + expect(engine.getCascadeDeleteObjects('cd_parent')).toEqual(['cd_child', 'cd_grandchild']); + // One level down, the walk starts there. + expect(engine.getCascadeDeleteObjects('cd_child')).toEqual(['cd_grandchild']); + }); + + it('control: an object nothing cascades into answers `[]`; one the registry does not hold answers `undefined`', async () => { + const { engine } = await makeEngine(); + expect(engine.getCascadeDeleteObjects('cd_loner')).toEqual([]); + expect(engine.getCascadeDeleteObjects('cd_grandchild')).toEqual([]); + expect(engine.getCascadeDeleteObjects('cd_no_such_object')).toBeUndefined(); + }); + + it('a self-cascading tree names itself once, and the walk terminates', async () => { + const { engine } = await makeEngine(); + expect(engine.getCascadeDeleteObjects('cd_folder')).toEqual(['cd_folder']); + }); + + it('agrees with the delete: the objects whose rows a delete removed are exactly the ones named', async () => { + const { engine, driver } = await makeEngine(); + const p = await engine.insert('cd_parent', { name: 'P' }, SYSTEM); + const c = await engine.insert('cd_child', { name: 'C', parent: p.id }, SYSTEM); + await engine.insert('cd_grandchild', { name: 'G', child: c.id }, SYSTEM); + await engine.insert('cd_tag', { name: 'T', parent: p.id }, SYSTEM); + await engine.insert('cd_note', { name: 'N', child: c.id }, SYSTEM); + const others = ['cd_child', 'cd_grandchild', 'cd_tag', 'cd_note']; + const before = Object.fromEntries(others.map((o) => [o, driver.rowIds(o).length])); + + const named = engine.getCascadeDeleteObjects('cd_parent'); + await engine.delete('cd_parent', { where: { id: p.id }, context: { isSystem: true } } as any); + + const emptied = others.filter((o) => before[o] > 0 && driver.rowIds(o).length === 0); + // Non-vacuity: the delete reached two levels, and left the set_null holders standing. + expect(emptied).toEqual(['cd_child', 'cd_grandchild']); + expect(driver.rowIds('cd_tag')).toHaveLength(1); + expect(driver.rowIds('cd_note')).toHaveLength(1); + expect(named).toEqual(emptied); + }); +}); + +describe('[#28001] the object read serves `cascadeDeleteObjects` beside the item, from the engine', () => { + it('both doors name the cascade, beside `item`, and `item` carries no such key', async () => { + const { protocol } = await makeHost(); + + const read: any = await protocol.getMetaItem({ type: 'object', name: 'cd_parent' } as any); + expect(read.cascadeDeleteObjects).toEqual(['cd_child', 'cd_grandchild']); + expect(read.item.name).toBe('cd_parent'); + expect(read.item).not.toHaveProperty('cascadeDeleteObjects'); + + const cached: any = await protocol.getMetaItemCached({ type: 'object', name: 'cd_parent' }); + expect(cached.cascadeDeleteObjects).toEqual(['cd_child', 'cd_grandchild']); + expect(cached.data).not.toHaveProperty('cascadeDeleteObjects'); + }); + + it('control: `[]` where nothing cascades; no key on the execution face', async () => { + const { protocol } = await makeHost(); + expect(((await protocol.getMetaItem({ type: 'object', name: 'cd_loner' } as any)) as any).cascadeDeleteObjects) + .toEqual([]); + expect(await protocol.getMetaItemForExecution({ type: 'object', name: 'cd_parent' } as any)) + .not.toHaveProperty('cascadeDeleteObjects'); + }); + + it('an object registered later that cascades into this one is on the next read', async () => { + const { engine, protocol } = await makeHost(); + expect(((await protocol.getMetaItem({ type: 'object', name: 'cd_loner' } as any)) as any).cascadeDeleteObjects) + .toEqual([]); + engine.registry.registerObject({ + name: 'cd_loner_line', label: 'Loner line', + fields: { id, name, loner: { name: 'loner', type: 'master_detail', reference: 'cd_loner', required: true } }, + } as any, '__test__'); + expect(((await protocol.getMetaItem({ type: 'object', name: 'cd_loner' } as any)) as any).cascadeDeleteObjects) + .toEqual(['cd_loner_line']); + }); +}); diff --git a/packages/objectql/src/engine-delete-cascade.ts b/packages/objectql/src/engine-delete-cascade.ts index 67c81eaa479..45c2de0d292 100644 --- a/packages/objectql/src/engine-delete-cascade.ts +++ b/packages/objectql/src/engine-delete-cascade.ts @@ -44,6 +44,14 @@ * site that calls it (`organizationOwnedRowCounts`). * - The bodies of a few lines that would not pay for their host. * + * ## [#28001] The one relation scan, shared + * + * Added after the move: `cascadeRelationsInto`, the collection walk's scan of + * which relations cascade into an object, lifted out of + * `collectCascadeDeleteSet` unedited so `collectCascadeDeleteObjects` (the + * object-level closure `ObjectQL.getCascadeDeleteObjects` serves to the + * metadata read) reads relations through it too, never through a copy. + * * ## Module helpers * * The bodies read four top-level `engine.ts` helpers that staying bodies @@ -168,6 +176,77 @@ export function recordReferenceCheckElevation( ); } +/** + * [#28001] The relations a delete of a record of `target` cascades through: + * every field of every object in `objects` whose resolved behaviour, read by + * {@link DeleteCascadeHost.cascadeRelationBehavior}, is `cascade`. In + * `objects` order, then field order. + * + * The ONE answer to "which relations cascade into this object". The delete's + * collection walk ({@link collectCascadeDeleteSet}) asks it for each object + * it reaches, and the object-level closure the metadata read serves + * ({@link collectCascadeDeleteObjects}) asks it too, so the list a delete + * confirmation names and the rows the delete removes cannot be chosen by two + * rules. + */ +export function cascadeRelationsInto( + this: Pick, + target: string, + objects: ReadonlyArray, +): Array<{ childName: string; fieldName: string; fdef: any }> { + const relations: Array<{ childName: string; fieldName: string; fdef: any }> = []; + for (const child of objects) { + const childName = (child as any)?.name as string | undefined; + const fields = (child as any)?.fields as Record | undefined; + if (!childName || !fields) continue; + for (const [fieldName, fdef] of Object.entries(fields)) { + if (this.cascadeRelationBehavior(target, child, fieldName, fdef) === 'cascade') { + relations.push({ childName, fieldName, fdef }); + } + } + } + return relations; +} + +/** + * [#28001] The body of `ObjectQL#getCascadeDeleteObjects`; the method's + * docblock there is its contract. + * + * The object-level closure of {@link collectCascadeDeleteSet}: breadth-first + * from `object` through {@link cascadeRelationsInto}, reading no row. Each + * object is named once, at the first depth that reaches it, and expanded + * once, so a cycle (a self-cascading tree, two objects cascading into each + * other) terminates. `object` itself is named only when a cascading relation + * leads back to it. + */ +export function collectCascadeDeleteObjects( + this: Pick, + object: string, + objects: ReadonlyArray, +): string[] { + const named: string[] = []; + const listed = new Set(); + const expanded = new Set([object]); + let frontier = [object]; + while (frontier.length > 0) { + const next: string[] = []; + for (const target of frontier) { + for (const { childName } of cascadeRelationsInto.call(this, target, objects)) { + if (!listed.has(childName)) { + listed.add(childName); + named.push(childName); + } + if (!expanded.has(childName)) { + expanded.add(childName); + next.push(childName); + } + } + } + frontier = next; + } + return named; +} + /** The body of `ObjectQL#collectCascadeDeleteSet`; the method's docblock there is its contract. */ export async function collectCascadeDeleteSet( this: Pick, @@ -191,17 +270,7 @@ export async function collectCascadeDeleteSet( const cascadingInto = (target: string) => { let relations = cascading.get(target); if (!relations) { - relations = []; - for (const child of objects) { - const childName = (child as any)?.name as string | undefined; - const fields = (child as any)?.fields as Record | undefined; - if (!childName || !fields) continue; - for (const [fieldName, fdef] of Object.entries(fields)) { - if (this.cascadeRelationBehavior(target, child, fieldName, fdef) === 'cascade') { - relations.push({ childName, fieldName, fdef }); - } - } - } + relations = cascadeRelationsInto.call(this, target, objects); cascading.set(target, relations); } return relations; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a50936956b9..d2c90709007 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -417,7 +417,8 @@ import type { READABLE_IDS_MAX_CHAIN_DEPTH } from './engine-readable-ids-permiss // names below hand each body the members it reads (`DeleteCascadeHost`). // Imported after every module those bodies import, so the engine's module // evaluation order is what it was. -import { collectCascadeDeleteSet, deleteRestrictedError, recordReferenceCheckElevation, referenceProbeFilter, refuseRestrictsInsideOwnCascade } from './engine-delete-cascade.js'; +// [#28001] …and the object-level closure of that walk the metadata read serves. +import { collectCascadeDeleteObjects, collectCascadeDeleteSet, deleteRestrictedError, recordReferenceCheckElevation, referenceProbeFilter, refuseRestrictsInsideOwnCascade } from './engine-delete-cascade.js'; import { applyInMemoryAggregation } from './in-memory-aggregation.js'; import { resolveEngineDeleteDispatch, @@ -10719,6 +10720,44 @@ export class ObjectQL implements IObjectQLEngine { return members; } + /** + * [#28001] `IObjectQLEngine.getCascadeDeleteObjects`: the objects a delete + * of a record of `object` can cascade into, which the metadata read serves + * beside the object as `cascadeDeleteObjects` so a delete confirmation can + * name them (ruling B on #28001: the client gets the list from the server, + * never from a second copy of the engine rule). + * + * Read from the rule the delete runs: breadth-first through the relations + * whose resolved behaviour is `cascade` ({@link cascadeRelationBehavior}, + * through the same scan {@link collectCascadeDeleteSet} makes at each + * depth), so a `master_detail` child is named by default and a lookup only + * when it declares `deleteBehavior: 'cascade'`. A child's own cascading + * children are named too, since the delete reaches them through it. Each + * object once, in the order the walk first reaches it; `object` itself only + * when a cascading relation leads back to it. + * + * It is object-level, and that is the whole of its disclosure: it reads the + * registry and no row, so it says what a delete MAY remove, never which + * records or how many. {@link inspectCascadeDelete} is the record-level + * reading, as SYSTEM, for the operator's command, and is not served. + * + * What it leaves out is what removes nothing: a `restrict` relation (it + * refuses the delete) and a `set_null` one (it clears the reference), + * as {@link inspectCascadeDelete} leaves them out. + * + * `undefined` when the registry does not hold `object`: the read then + * serves no key, rather than `[]`, which says nothing cascades. + */ + getCascadeDeleteObjects(object: string): string[] | undefined { + if (!this._registry.getObject(object)) return undefined; + const host: ThisParameterType = { + cascadeRelationBehavior: (...a) => this.cascadeRelationBehavior(...a), + }; + return collectCascadeDeleteObjects.call( + host, this.resolveObjectName(object), this._registry.getAllObjects(), + ); + } + /** * Register the crypto provider that backs `secret`-typed fields. * diff --git a/packages/rest/src/meta-app-bundle-routes.test.ts b/packages/rest/src/meta-app-bundle-routes.test.ts index 1530c572720..bed9a96270d 100644 --- a/packages/rest/src/meta-app-bundle-routes.test.ts +++ b/packages/rest/src/meta-app-bundle-routes.test.ts @@ -333,3 +333,35 @@ describe('[#27627] an app the caller cannot read refuses the whole request with expect(protocol.getMetaItem).not.toHaveBeenCalled(); }); }); + +// ── [#28001] The objects a delete cascades into ─────────────────────────────── + +describe('[#28001] a bundled object carries `cascadeDeleteObjects` exactly as its own item read serves it', () => { + it('the bundle\'s entry and `GET /meta/object/{name}` answer the same list, beside the item', async () => { + const { protocol, route } = setup('member'); + const plain = protocol.getMetaItem.getMockImplementation(); + protocol.getMetaItem.mockImplementation(async (request: any) => { + const envelope = await plain(request); + return singular(request.type) === 'object' && request.name === 'account' + ? { ...envelope, cascadeDeleteObjects: ['note'] } + : envelope; + }); + const read = async (path: string, params: Record) => { + const res = makeRes(); + await route(path).handler({ method: 'GET', path, params, query: {}, body: {}, headers: {} }, res); + return res; + }; + + const bundled = await read(`${META}/app/:appId/bundle`, { appId: 'crm' }); + expect(bundled.statusCode).toBe(200); + expect(AppMetadataBundleResponseSchema.safeParse(bundled.body).success).toBe(true); + const account = bundled.body.objects.find((m: any) => m.name === 'account'); + expect(account.cascadeDeleteObjects).toEqual(['note']); + expect(account.item).not.toHaveProperty('cascadeDeleteObjects'); + // The control: an object the engine did not answer for carries no key. + expect(bundled.body.objects.find((m: any) => m.name === 'contact')).not.toHaveProperty('cascadeDeleteObjects'); + + const single = await read(`${META}/:type/:name`, { type: 'object', name: 'account' }); + expect(JSON.stringify(account)).toBe(JSON.stringify(single.body)); + }); +}); diff --git a/packages/rest/src/meta-item-read-routes.ts b/packages/rest/src/meta-item-read-routes.ts index 12e51bed874..b3f2d1dde21 100644 --- a/packages/rest/src/meta-item-read-routes.ts +++ b/packages/rest/src/meta-item-read-routes.ts @@ -782,11 +782,24 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M const cachedDisabledActions = metaType === 'object' && result.disabledActions !== undefined ? { disabledActions: result.disabledActions } : {}; - const cachedBeside = { ...cachedRegistration, ...cachedDisabledActions }; + // [#28001] …and the objects a delete of one of its + // records can cascade into, beside the document on + // the same terms as `disabledActions`: read live, + // folded into the validator below + // (`cascadeDeleteObjectsFingerprint`), so an object + // registered later that cascades into this one moves + // the ETag. + const cachedCascadeDeleteObjects = metaType === 'object' && result.cascadeDeleteObjects !== undefined + ? { cascadeDeleteObjects: result.cascadeDeleteObjects } + : {}; + const cachedBeside = { ...cachedRegistration, ...cachedDisabledActions, ...cachedCascadeDeleteObjects }; // Only what is beside the document and in NO // validator stands the 304 down — `registration`. const besideJudged = Object.keys(cachedRegistration).length > 0; const disabledActionsFingerprint = cachedDisabledActionsFingerprint(cachedDisabledActions.disabledActions); + const cascadeDeleteObjectsFingerprint = cachedDisabledActionsFingerprint( + cachedCascadeDeleteObjects.cascadeDeleteObjects, + ); if (result.notModified) { res.status(304).send(); @@ -882,18 +895,24 @@ export function registerMetaItemReadRoutes(this: MetaItemReadRouteHost, scope: M // does the switched-off list served beside an // object: a switch flipped off or on is never // answered a `304` for the list it replaced. + // [#28001] …and so does the cascade list served + // beside an object: a cascading object registered + // or removed is never answered a `304`. const value = foldVisibilityFingerprintIntoEtag( foldVisibilityFingerprintIntoEtag( foldVisibilityFingerprintIntoEtag( foldVisibilityFingerprintIntoEtag( - foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint), - runtimeView.fingerprint, + foldVisibilityFingerprintIntoEtag( + foldVisibilityFingerprintIntoEtag(result.etag.value, visibilityFingerprint), + runtimeView.fingerprint, + ), + intakeFingerprint, ), - intakeFingerprint, + audienceFingerprint, ), - audienceFingerprint, + disabledActionsFingerprint, ), - disabledActionsFingerprint, + cascadeDeleteObjectsFingerprint, ); const etagValue = result.etag.weak ? `W/"${value}"` @@ -1254,6 +1273,9 @@ function anonymousFormIntakeFingerprint(warnings: ReadonlyArray<{ path: string; * the representation sent. Absent (no ledger answered) folds nothing and keeps * the ETag byte-identical; `[]` (a ledger that switches none of them off) folds * a fingerprint of its own, so a ledger attaching or detaching moves it too. + * + * [#28001] The same dimension, on the same terms, for the + * `cascadeDeleteObjects` served beside an object: a name list hashed as served. */ function cachedDisabledActionsFingerprint(disabledActions: readonly string[] | undefined): string { return disabledActions === undefined ? '' : objectFieldVisibilityFingerprint([JSON.stringify(disabledActions)]); diff --git a/packages/rest/src/meta-object-cached-cascade-delete-objects.test.ts b/packages/rest/src/meta-object-cached-cascade-delete-objects.test.ts new file mode 100644 index 00000000000..2598f9690b4 --- /dev/null +++ b/packages/rest/src/meta-object-cached-cascade-delete-objects.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #28001 — the DEFAULT `GET /meta/object/:name` carries, beside the object, the +// objects a delete of one of its records can cascade into, so the console's +// delete confirmation can name them without a second request. +// +// `metadata.enableCache` defaults to `true`, so a default deployment answers +// this read from the CACHED branch, which rebuilds the envelope as +// `{ type, name, item }`. The uncached read serves `cascadeDeleteObjects` +// beside `lock`; this branch has to carry it too. +// +// An object's validator is folded and judged HERE (`audienceFolds`), so this +// file also pins the 304 half, on `disabledActions`' (#28044) terms: the served +// list is folded into the validator, so an unchanged object with an unchanged +// cascade is answered 304, an object registered later that cascades into it +// answers 200 under a new ETag, and an object no engine answered for keeps the +// ETag it had before. +// +// The protocol double answers `getMetaItemCached` the way `metadata-protocol`'s +// producer does (pinned on the real producer in +// `protocol.object-cascade-delete-objects.test.ts`). + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +const ANON_API = { api: { requireAuth: false } }; + +const LEAD = { + name: 'crm_lead', + label: 'Lead', + fields: { title: { name: 'title', type: 'text', label: 'Title' } }, +}; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn((k: string, v: string) => { res.headers[k] = v; return res; }); + res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); res.send = vi.fn(); + return res; +} + +/** `cascadeDeleteObjects` as the producer resolved it: the names, `[]`, or `undefined` (no engine answered). */ +function setup(cascadeDeleteObjects: string[] | undefined) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([]), + getMetaItem: vi.fn(), + findData: vi.fn().mockResolvedValue([]), + getMetaItemCached: vi.fn(async () => ({ + data: JSON.parse(JSON.stringify(LEAD)), + ...(cascadeDeleteObjects !== undefined ? { cascadeDeleteObjects } : {}), + etag: { value: 'etag_lead', weak: false }, + cacheControl: { directives: ['private', 'no-cache'] }, + notModified: false, + })), + }; + const rest = new RestServer(mockServer() as any, protocol as any, ANON_API as any); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: [] }); + rest.registerRoutes(); + return { rest, protocol }; +} + +async function getItem(rest: any, type: string, name: string, headers: Record = {}) { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + if (!route) throw new Error('GET /api/v1/meta/:type/:name route not registered'); + const res = makeRes(); + await route.handler({ method: 'GET', params: { type, name }, query: {}, body: {}, headers }, res); + return res; +} + +describe('the default cached `GET /meta/object/:name` carries `cascadeDeleteObjects` beside the object (#28001)', () => { + it('the objects a delete cascades into are named beside the item, never inside it', async () => { + const { rest, protocol } = setup(['crm_campaign_member']); + + const res = await getItem(rest, 'object', 'crm_lead'); + + // The default branch, measured — not the uncached read. + expect(protocol.getMetaItemCached).toHaveBeenCalledTimes(1); + expect(protocol.getMetaItem).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(res.body.cascadeDeleteObjects).toEqual(['crm_campaign_member']); + expect(res.body.item).not.toHaveProperty('cascadeDeleteObjects'); + }); + + it('`[]` from an engine that answered nothing cascades is served as `[]`', async () => { + const res = await getItem(setup([]).rest, 'object', 'crm_lead'); + expect(res.statusCode).toBe(200); + expect(res.body.cascadeDeleteObjects).toEqual([]); + }); + + it('control: where no engine answered, the envelope carries no key at all', async () => { + const res = await getItem(setup(undefined).rest, 'object', 'crm_lead'); + expect(res.statusCode).toBe(200); + expect(res.body).not.toHaveProperty('cascadeDeleteObjects'); + }); +}); + +describe('the cached `GET /meta/object/:name` folds `cascadeDeleteObjects` into its ETag (#28001)', () => { + /** Read once, then revalidate with the ETag that answer issued — against `next` as the engine then answers. */ + async function revalidate(first: string[] | undefined, next: string[] | undefined) { + const served = await getItem(setup(first).rest, 'object', 'crm_lead'); + expect(served.statusCode).toBe(200); + const etag = served.headers.ETag; + expect(typeof etag).toBe('string'); + const again = await getItem(setup(next).rest, 'object', 'crm_lead', { 'if-none-match': etag }); + return { etag, again }; + } + + it('an unchanged object with an unchanged cascade is answered 304', async () => { + const { again } = await revalidate(['crm_campaign_member'], ['crm_campaign_member']); + expect(again.statusCode).toBe(304); + expect(again.body).toBeUndefined(); + }); + + it('an object registered later that cascades into this one moves the ETag: 200 with the new list', async () => { + const { etag, again } = await revalidate(['crm_campaign_member'], ['crm_campaign_member', 'crm_lead_note']); + expect(again.statusCode).toBe(200); + expect(again.body.cascadeDeleteObjects).toEqual(['crm_campaign_member', 'crm_lead_note']); + expect(again.headers.ETag).not.toBe(etag); + }); + + it('control: an object no engine answered for keeps the validator it had before, and its 304', async () => { + const none = await revalidate(undefined, undefined); + expect(none.again.statusCode).toBe(304); + expect(none.etag).toBe('"etag_lead"'); + }); +}); diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 3249d7097b6..bb3a94f4008 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -756,6 +756,7 @@ "api/GetMetaItemCachedRequest:name", "api/GetMetaItemCachedRequest:type", "api/GetMetaItemCachedResponse:cacheControl", + "api/GetMetaItemCachedResponse:cascadeDeleteObjects", "api/GetMetaItemCachedResponse:data", "api/GetMetaItemCachedResponse:disabledActions", "api/GetMetaItemCachedResponse:etag", @@ -790,6 +791,7 @@ "api/GetMetaItemRequest:previewDrafts", "api/GetMetaItemRequest:state", "api/GetMetaItemRequest:type", + "api/GetMetaItemResponse:cascadeDeleteObjects", "api/GetMetaItemResponse:deletable", "api/GetMetaItemResponse:disabledActions", "api/GetMetaItemResponse:draftChanges", diff --git a/packages/spec/src/api/meta-item-response-shapes.test.ts b/packages/spec/src/api/meta-item-response-shapes.test.ts index 636bb43612d..b93c5ac52e3 100644 --- a/packages/spec/src/api/meta-item-response-shapes.test.ts +++ b/packages/spec/src/api/meta-item-response-shapes.test.ts @@ -436,3 +436,46 @@ describe('`disabledActions` beside an object: the names, `[]`, or absent (#27842 if (!verdict.success) expect(verdict.error.issues[0]?.path).toEqual(['disabledActions', 0]); }); }); + +/** + * [#28001] `cascadeDeleteObjects` beside an object, on both item reads: typed + * as a list of object names, optional, and never defaulted — absence means no + * engine answered. The list read carries none. + */ +export type CascadeDeleteObjectsAreNamesOrAbsent = Assert< Eq< GetMetaItemResponse['cascadeDeleteObjects'], string[] | undefined > >; +export type CachedCascadeDeleteObjectsAreNamesOrAbsent = Assert< Eq< GetMetaItemCachedResponse['cascadeDeleteObjects'], string[] | undefined > >; + +describe('`cascadeDeleteObjects` beside an object: the names, `[]`, or absent (#28001)', () => { + const LEAD = { name: 'crm_lead', fields: { name: { name: 'name', type: 'text' } } }; + + it('keeps the names through a parse, beside an item that does not carry them', () => { + const parsed = GetMetaItemResponseSchema.parse({ + type: 'object', name: 'crm_lead', item: LEAD, cascadeDeleteObjects: ['crm_campaign_member'], + }); + expect(parsed.cascadeDeleteObjects).toEqual(['crm_campaign_member']); + expect(parsed.item).toEqual(LEAD); + }); + + it('`[]` survives a parse as `[]`, and an absent key stays ABSENT — never defaulted to `[]`', () => { + expect(GetMetaItemResponseSchema.parse({ type: 'object', name: 'crm_lead', item: LEAD, cascadeDeleteObjects: [] }) + .cascadeDeleteObjects).toEqual([]); + expect('cascadeDeleteObjects' in GetMetaItemResponseSchema.parse({ type: 'object', name: 'crm_lead', item: LEAD })) + .toBe(false); + expect('cascadeDeleteObjects' in GetMetaItemCachedResponseSchema.parse({ data: LEAD, notModified: false })).toBe(false); + }); + + it('the cached default door carries it beside `data`', () => { + const parsed = GetMetaItemCachedResponseSchema.parse({ + data: LEAD, notModified: false, cascadeDeleteObjects: ['crm_campaign_member'], + }); + expect(parsed.cascadeDeleteObjects).toEqual(['crm_campaign_member']); + }); + + it('refuses an entry that is not an object name string, located at the key', () => { + const verdict = GetMetaItemResponseSchema.safeParse({ + type: 'object', name: 'crm_lead', item: LEAD, cascadeDeleteObjects: [{ object: 'crm_campaign_member' }], + }); + expect(verdict.success).toBe(false); + if (!verdict.success) expect(verdict.error.issues[0]?.path).toEqual(['cascadeDeleteObjects', 0]); + }); +}); diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 50c901126a1..766cd3b223e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -641,6 +641,22 @@ export type MetadataAuthoringClient = z.input z.object({ + 'is the answering process\'s engine\'s. Beside `item`, never inside it: `item.actions` is served as ' + 'declared, a switched-off action included.', ), + cascadeDeleteObjects: z.array(z.string()).optional().describe( + 'The objects, by name, that deleting a record of this object can also delete: every object whose ' + + 'relation into it cascades (a `master_detail` by default, a `lookup` or `tree` that declares ' + + '`deleteBehavior: \'cascade\'`), and the objects that cascade into those in turn, each once, in the ' + + 'order the delete reaches them. This object itself is named only when a cascading relation leads back ' + + 'to it. A `restrict` or `set_null` relation removes nothing and is not named. Read from the rule the ' + + 'delete runs, and object-level: it never says which records or how many. Present only on an `object` ' + + 'read answered by a process whose data engine answers it, and then `[]` when nothing cascades. Absent ' + + 'otherwise, and absence means no engine answered, never that nothing cascades. Unlike the protection ' + + 'keys it is published by the cached branch too, read live and folded into the ETag; it is absent on a ' + + 'draft read. Beside `item`, never inside it: it is derived from other objects\' definitions and is not ' + + 'authorable.', + ), draftChanges: z.array(z.object({ path: z.array(z.string().min(1)).describe( 'The changed key path inside `item`: one object key per segment, from the body root down to the ' @@ -2388,6 +2417,11 @@ export const GetMetaItemCachedRequestSchema = lazySchema(() => z.object({ * `GET /meta/object/:name` does not rely on that stand-down: it folds the * served list into the ETag it checks (#28044), so its 304 is safe and comes * back only while the switched-off set is unchanged. + * + * [#28001] …and `cascadeDeleteObjects`, on the same terms as + * `disabledActions`: an object registered later that cascades into this one + * moves no byte of this document, so this method stands its 304 down for a + * read that carries the list, and the REST door folds it into its ETag. */ export const GetMetaItemCachedResponseSchema = lazySchema(() => MetadataCacheResponseSchema.extend({ registration: FlowRegistrationSchema.optional().describe( @@ -2405,6 +2439,14 @@ export const GetMetaItemCachedResponseSchema = lazySchema(() => MetadataCacheRes + '304 comes back only while the switched-off set is unchanged). Absent otherwise, and absence means no ledger answered, never that nothing is ' + 'switched off.', ), + cascadeDeleteObjects: z.array(z.string()).optional().describe( + 'The objects, by name, that deleting a record of the object in `data` can also delete (the item read\'s ' + + '`cascadeDeleteObjects`), read live on every call and never part of `etag`. Present only for an ' + + '`object` read answered by a process whose data engine answers it, `[]` when nothing cascades; this ' + + 'method never answers `notModified` for a read that carries it (the REST door folds the served list ' + + 'into the ETag it checks instead). Absent otherwise, and absence means no engine answered, never that ' + + 'nothing cascades.', + ), })); /** diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 349ea2c322f..d4850732e9e 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -230,9 +230,10 @@ export type EngineFilterJudgement = * guarded call into a `possibly undefined` error and push code back toward the * `any` this contract exists to remove. * - * Two members are optional by ruling, not by this default: {@link judgeFilter} - * (#19995 ruling C) and {@link getDisabledActions} (#27842 ruling letter B, - * the `getFlowRegistration?` shape). Their callers must work with an engine + * Three members are optional by ruling, not by this default: {@link judgeFilter} + * (#19995 ruling C), {@link getDisabledActions} (#27842 ruling letter B, + * the `getFlowRegistration?` shape) and {@link getCascadeDeleteObjects} + * (#28001, that shape again). Their callers must work with an engine * that lacks them, so the optional mark states that requirement in the type. */ export interface IObjectQLEngine extends IDataEngine { @@ -351,6 +352,31 @@ export interface IObjectQLEngine extends IDataEngine { */ getDisabledActions?(): string[] | undefined; + /** + * 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 + * 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`. + * + * [#28001] Declared for the metadata read, which serves it beside an + * object as `cascadeDeleteObjects` (`GetMetaItemResponseSchema`, + * `api/protocol.zod.ts`) so a delete confirmation can name what the + * delete also removes. Read from the rule the delete runs, never from a + * copy of it, and object-level: it reads no row, so it never says which + * records or how many. The metadata protocol reads it through the + * kernel's service table by name (`objectql`), as it reads + * {@link getDisabledActions}. + * + * Optional, as {@link getDisabledActions} is: the read serves no key + * where the engine does not declare it. + */ + getCascadeDeleteObjects?(objectName: string): string[] | undefined; + // ── Hook / middleware seams ────────────────────────────────────────── /** * Register a code-path hook. diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 7c773a384d2..3b0c2dbfaa0 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1236,6 +1236,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.object-cascade-delete-objects.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.object-disabled-actions.test.ts", "verb": "findOne",