Skip to content

Commit 584e737

Browse files
feat(spec,objectql,runtime): declare HookContext.referentialCascadeDelete, the delete twin of referentialFieldClear (#23458)
Fixes #23221 Clause-②: yes ## What Declares `HookContext.referentialCascadeDelete`, the delete twin of `referentialFieldClear`. The engine sets it on every child delete its cascade issues, and it is carried into the QuickJS sandbox by contract. - `true` exactly on each child delete that `cascadeDeleteRelations` issues for a relation whose `deleteBehavior` resolves to `cascade`, in both phases (`beforeDelete`, `afterDelete`) and at any depth: a grandchild that the child's own cascade removes is marked too. - Absent, never `false`, on every other dispatch. That covers the delete the caller asked for, a hand delete of the same child, the `set_null` cleanup update (which keeps `referentialFieldClear` and never carries this key), and any write a cascaded child's hooks issue through `ctx.api`, deletes included. - Named consumers: HotCRM's freeze guards on quote lines and deal lines (objectstack-ai/hotcrm#2078, ruling C). With the key, a guard can refuse a hand delete of a settled line and yield to the parent's cascade. ## Where it landed, and one departure from the dispatch's mechanism assumption The landing sites are the card's: `packages/spec/src/data/hook.zod.ts` beside `referentialFieldClear`; `packages/objectql/src/engine.ts` (the cascade child-delete site, the `delete()` hook-context assembly, and a helper beside `buildReferentialFieldClear`); `packages/runtime/src/sandbox/` (`body-runner.ts` `buildSandboxContext`, `quickjs-runner.ts` `installCtx`, `script-runner.ts` `ScriptContext`). The dispatch assumed the engine would stamp a `__` marker on the child delete's context, which is the twin's trust model. I measured that route and did not take it, for two reasons: 1. **It would leak into the child's hook writes.** `buildHookApi(opCtx.context)` hands the delete's own context object to `ScopedContext`, whose repositories pass it unchanged into every write (`ObjectRepository.deleteById` / `delete` / `update`), and `ObjectQL.transaction` spreads it (`{ ...baseContext, transaction }`). So a key stamped on the child's context rides into every write the child's hooks issue through `ctx.api`. A hook's own delete during the cascade would then read as cascade-issued, which contradicts "true exactly on each child delete the cascade issues, absent on every other dispatch". 2. **It would change what the child is handed.** A stamp needs a fresh object, `{ ...(context ?? {}), __x: true }`. Where no transaction is opened, that turns an absent caller context into a present one, and both `buildSession(undefined)` (returns `undefined`) and `buildHookApi(undefined)` (falls back to `{ isSystem: true }`) would move. The engine already rejected a context key for the sibling cascade state for exactly this reason; see the `cascadeDeleteSets` docblock. **What I did instead.** The walk issues each child delete inside an engine-private `AsyncLocalStorage` token that names that child's object and id (`cascadeIssuedDeletes`, beside `cascadeDeleteSets`). `delete()` claims a matching, unclaimed token once, before any middleware or hook runs, and projects it onto the declared key. The context still reaches the child by reference and unchanged, as before. **Consequence for the contract** (written into the docblock and the changeset): this fact has no operation-private spelling at all. The declared key is the only channel, and there is nothing on `ctx.api`, `ctx.transaction` or a `__` key to sniff instead, so the objectstack-ai/hotcrm#1165 ruling holds by construction. The key is a signal to app code, not an authorization input: each child delete is still authorized as the caller, and plugin-security reads nothing new. ## Pins, one per face - **spec** `packages/spec/src/data/hook.test.ts`: the key survives a parse, typed; it is absent (not `false`) when not carried; and it is a key of its own (neither twin appears on the other's parsed context). - **objectql** `packages/objectql/src/engine-cascade-delete.test.ts`, new `describe` with 6 cases: - the cascaded child carries the key in both phases, beside the inherited caller identity; the hand delete of the same child under the same identity, and the root delete, carry no key; - a grandchild removed by the child's cascade is marked; - in the same cascade, the `set_null` cleanup carries `referentialFieldClear` and not this key, and the cascaded child carries this key and not `referentialFieldClear`; - a delete and an update that a cascaded child's hooks issue through `ctx.api` are not marked; - a delete of the very same row from inside the cascaded dispatch finds the token already claimed; - an identity-less cascade still marks its child and puts no `__` key on the envelope. - **runtime** `packages/runtime/src/sandbox/referential-cascade-delete-signal.integration.test.ts`: real `ObjectQL` + `SqlDriver` (better-sqlite3) + `QuickJSScriptRunner` behind `hookBodyRunnerFactory`. A shipped body reads `ctx.referentialCascadeDelete === true` on the cascaded line in both phases, with the inherited `session.userId` present; on the hand delete of a sibling line under the same identity it reads the key as absent (`typeof` is `'undefined'`). - **cli** `packages/cli/test/lowered-body-face-enumeration.test.ts` derives both the in-process face and the body face from source, so it is a reader of this change. It stays green because the key is present on both faces. ## Ablations (one-shot, through `scripts/ablation-replace.mjs` WRAP mode) Each mutation was proven on disk (anchor 1 to 0, blob changed) and each restore was proven equal to the HEAD blob with an empty `git diff HEAD`. All five targets are reached through relative source imports, so no rebuild was needed. Expected directions were written down before running. | # | Mutation | Expected | Observed | |---|---|---|---| | A1 | delete the `delete()` projection spread (`engine.ts`) | the 6 new objectql pins red | 6 failed / 19 passed | | A2 | delete the `buildSandboxContext` carrier (`body-runner.ts`) | runtime pin red | 1 failed | | A3 | delete the `installCtx` `setProp` (`quickjs-runner.ts`) | runtime pin red | 1 failed | | A4 | delete `issued.claimed = true` (`engine.ts`) | only the same-row pin red | 1 failed / 24 passed | | A5 | delete the object/id key check (`engine.ts`) | green: no reachable mismatch | 25 passed | A5 is a characterisation, not a pin. The check is a stated defensive invariant, and it has no reachable counter-case at this head: the claim runs before any middleware or hook, so nothing can enter `delete()` inside the token scope first. See Acceptance notes. ## Tests Two heads, named. `670114eec` is this branch before it merged `main`; `7e4509b6cb` is the final head, which merged `main` at `92980cc027` plus one comment-only commit. - **Targeted pins** (on the branch, after the build, before the merge): - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data/hook.test.ts`: 96 passed; - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/engine-cascade-delete.test.ts`: 25 passed (19 existing + 6 new); - `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` on both sandbox signal pins (this one and the update twin's): 2 passed. - **Full suites at `670114eec`**, each through `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`): - `pnpm --filter @objectstack/spec test`: 674 files, 20,478 passed, 1 todo; - `pnpm --filter @objectstack/objectql test`: 415 files, 8,093 passed; - `typecheck` for spec, objectql and runtime: exit 0 each (each includes its `check:test-typecheck` leg). - **`check:generated`**: after the merge, at `92980cc027`, all 14 generated artifacts are up to date (exit 0). Before the merge, `--fix` regenerated only `check:docs`, the one artifact it proved stale. - **At `7e4509b6cb`**: the objectql rebuild exits 0. The full suites are queued behind the shared verify lock at the time this PR opens: spec, objectql, runtime and plugin-security tests; spec, objectql and runtime typechecks; and the cli face-enumeration pin (`--project unit`, the only tier this change owes). Their readings go in the `os-dev-report` on #23221. NOT MEASURED in this body: the runtime full suite (its first attempt never acquired the lock, exit 99, so nothing ran), plugin-security, and the cli face-enumeration pin. - **cli integration tier**: declared to CI. The diff touches no integration-tier file and no spawn entry. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, which takes the change set from the merge base: 11 paths, +671/-1, 115 commands. Every command was run byte for byte from that list, its exit recorded before any pipe, and the record reconciled with `--ran`. - **Final head `7e4509b6cb`:** 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN; `--ran` exit 0. All 115 commands exit 0. - **At `670114eec`, the same list:** 4 refusals, each fixed and none of them a finding. - `check-engine-split-ratio.mjs --days 90` (exit 2) and `check-plugin-teardown-shape.mjs --self-test` (exit 3) refused on the shallow clone. After `git fetch --shallow-since=2026-07-09 origin main`, both exit 0. - `check:skill-examples` and `check:dual-build-cjs-loads` returned PREREQUISITE NOT MET (some `dist/` absent). At the final head they measured and passed: 263 prose examples type-check, and 110 CJS entry points across 68 packages load. - **Families CI runs** are cited from the PR's check-runs in the report, not re-run here. - **Drift note:** `--ran` flags the tree as 7 commits behind `origin/main` at report time. Those commits change 6 gate-script files and `packages/spec/authorable-surface/data.json`, one line away from this branch's one-line addition there, so the merge is textual-clean. CI's `check:authorable-surface` runs on the merge ref. ## Acceptance notes - **The object/id key check in `claimReferentialCascadeDelete`** is kept as a defensive invariant ("the delete that claims the token is the one the walk issued"). Ablation A5 shows nothing at this head can reach a mismatch. Claim-once (A4) is the pinned half. - **Liveness ledger: no row is needed.** `HookContextSchema` is the runtime hook context, not a registered metadata type. `packages/spec/liveness/hook.json` covers `HookSchema`'s 15 authored keys only, and the update twin has no row either. `check:liveness` is green. - **Generated faces:** `packages/spec/authorable-surface/data.json` gains `data/HookContext:referentialCascadeDelete` (from the spec build), and `content/docs/references/data/hook.mdx` gains its row (`check:generated --fix`, where `check:docs` was the only artifact proved stale). `authorable-surface.base.json` is untouched. - **`engine.ts` regions touched:** the `CascadeIssuedDelete` interface (about :4131), the `cascadeIssuedDeletes` field (about :4292), `claimReferentialCascadeDelete` (about :6407), the cascade site (about :18750) and the `delete()` claim and projection (about :18880 and :18915). The serial constraint named PR #23412's regions (:5514, :8856, :20839); that PR has since landed on `main`, and this branch merged it cleanly. - **No behaviour moves for anything but the new key.** The child delete is still handed the caller's context by reference, and no authorization path reads the token. - **No ADR governs** the hook-context referential markers or the cascade dispatch's hook semantics (checked: `docs/adr` names neither `referentialFieldClear` nor `cascadeDeleteRelations`). --- _Generated by [Claude Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d231ba commit 584e737

11 files changed

Lines changed: 672 additions & 1 deletion

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
A hook can now tell a child delete that a parent's cascade issues from a hand delete: `HookContext.referentialCascadeDelete`, the delete twin of `referentialFieldClear`
8+
9+
Clause-②: yes
10+
11+
When a record is deleted and a relation whose `deleteBehavior` resolves to `cascade` (a `master_detail` unless it declares `restrict`, or a `lookup` / `tree` that declares `cascade`) removes its children, the engine deletes each child as the calling user. The child's `beforeDelete` and `afterDelete` hooks therefore saw the same `ctx.user`, `ctx.session` and `ctx.input` as a user deleting that one child by hand. A guard that freezes settled records, such as a quote's lines once the quote is accepted, could refuse both deletes or neither, and refusing both made the parent, and every record whose cascade reaches it, undeletable.
12+
13+
- **What the key means.** `ctx.referentialCascadeDelete === true` exactly on a delete the engine's cascade issued: the child delete, in both phases, at any depth (a grandchild that the child's own cascade removes is marked too). It is absent, never `false`, on every other delete: the delete the caller asked for, a user's hand delete of the same child, and any write a hook issues through `ctx.api` during a cascade, deletes included. The `set_null` cleanup update keeps `referentialFieldClear` and never carries this key, and a cascade child delete never carries `referentialFieldClear`.
14+
- **The guard idiom.** Refuse a hand delete of a frozen line and yield to the cascade:
15+
16+
```ts
17+
if (ctx.referentialCascadeDelete !== true && ctx.previous?.locked === true) {
18+
throw new Error('This line belongs to a settled quote and cannot be deleted.');
19+
}
20+
```
21+
22+
- **Only the declared key is the contract.** There is no operation-private (`__`-prefixed) spelling of this fact on the execution context. The engine recognises the delete its cascade issued through an engine-private record keyed on that child's object and id, so nothing on `ctx.api`, `ctx.transaction` or any `__` key can stand in for it. Read `ctx.referentialCascadeDelete`.
23+
- **In a sandboxed hook body.** The key is carried across the QuickJS boundary by contract, so a lowered `*.hook.ts` body reads `ctx.referentialCascadeDelete` exactly as an in-process handler does.
24+
- **spec (minor):** `HookContextSchema` declares `referentialCascadeDelete` (boolean, optional). One optional engine-produced key on the non-strict runtime hook context; nothing previously valid changes meaning.
25+
- **objectql (minor):** `delete()`'s hook-context assembly sets the key on the delete the cascade walk issues. The walk still hands the child the caller's context unchanged, so no other hook-context member moves.
26+
- **runtime (minor):** `buildSandboxContext` and `installCtx` carry the key into the VM.

‎content/docs/references/data/hook.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
9999
| **session** | `{ userId?: string; actor?: string; organizationId?: string; accessToken?: string; … }` | optional | Current session context |
100100
| **provenance** | `{ flowRunId?: string; attributedUserId?: string; performedByClientId?: string }` | optional | Server-stamped write provenance (never client-supplied, never an authorization input) |
101101
| **referentialFieldClear** | `boolean` | optional | Engine-produced marker: true exactly when this write is the engine's own referential cleanup (the set_null cascade UPDATE clearing — or, for multiple: true, removing the deleted member from — a lookup that references a record being deleted). Absent on every other write, including a user hand-clearing the same lookup; read as `ctx.referentialFieldClear === true`. The declared projection of the operation-private `__referentialFieldClear`, carried across the sandbox boundary by contract; server-derived and never client-supplied. |
102+
| **referentialCascadeDelete** | `boolean` | optional | Engine-produced marker: true exactly when this delete is one the engine's cascade issued (the child delete removing a row that references a record being deleted through a relation whose deleteBehavior resolves to cascade, at any depth), in both phases of that delete. Absent on every other delete, including a user hand-deleting the same child and a delete a hook issues through ctx.api; read as `ctx.referentialCascadeDelete === true`. The delete twin of referentialFieldClear, carried across the sandbox boundary by contract; server-derived, never client-supplied, and with no operation-private spelling to read instead. |
102103
| **transaction** | `any` | optional | Database transaction handle |
103104
| **ql** | `any` | ✅ | ObjectQL Engine Reference |
104105
| **api** | `any` | optional | Cross-object data access (IScopedContext — `object(name)` + `transaction(cb)`) |

‎packages/objectql/src/engine-cascade-delete.test.ts‎

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,3 +787,240 @@ describe('cascadeDeleteRelations — [#13644] every reference-cleanup write carr
787787
expect(seen[0].sessionUserId).toBeUndefined();
788788
});
789789
});
790+
791+
// [#23221] The DECLARED cascade-delete marker — `HookContext.
792+
// referentialCascadeDelete`, the delete twin of the block above. The cascade
793+
// deletes each child AS THE CALLER (the walk recurses through the public
794+
// `delete()` with the caller's context), so `ctx.user`, `ctx.session` and
795+
// `ctx.input` are identical between a child the cascade removes and a hand
796+
// delete of the same child; this key is the one discriminator. One pin per
797+
// face: the cascade child (both phases, at any depth), its hand-delete control
798+
// under the SAME identity, the set_null cleanup that must carry the update twin
799+
// and not this key, and the writes a cascaded child's hooks issue through
800+
// `ctx.api`, which must not be marked. The engine keeps no envelope key for
801+
// this fact; the last pin holds that too.
802+
describe('cascadeDeleteRelations — [#23221] every cascade-issued child delete carries the declared ctx.referentialCascadeDelete', () => {
803+
let engine: ObjectQL;
804+
805+
const CALLER = { userId: 'u1', isSystem: true };
806+
807+
// A frozen-lines shape: a master_detail child (cascade by default) and its
808+
// own master_detail grandchild, plus an unrelated object a child's hook can
809+
// write to through `ctx.api`.
810+
const qline = {
811+
name: 'qline',
812+
label: 'Quote Line',
813+
fields: {
814+
id: { name: 'id', type: 'text' as const, primaryKey: true },
815+
title: { name: 'title', type: 'text' as const },
816+
parent: { name: 'parent', type: 'master_detail' as const, reference: 'acct' },
817+
},
818+
};
819+
const qlineNote = {
820+
name: 'qline_note',
821+
label: 'Line Note',
822+
fields: {
823+
id: { name: 'id', type: 'text' as const, primaryKey: true },
824+
body: { name: 'body', type: 'text' as const },
825+
line: { name: 'line', type: 'master_detail' as const, reference: 'qline' },
826+
},
827+
};
828+
const sideLog = {
829+
name: 'side_log',
830+
label: 'Side Log',
831+
fields: {
832+
id: { name: 'id', type: 'text' as const, primaryKey: true },
833+
body: { name: 'body', type: 'text' as const },
834+
},
835+
};
836+
837+
type Cap = {
838+
object: unknown; event: unknown; id: unknown;
839+
marker: unknown; hasKey: boolean; hasFieldClear: boolean; sessionUserId: unknown;
840+
};
841+
const capture = (ctx: any): Cap => ({
842+
object: ctx.object,
843+
event: ctx.event,
844+
id: ctx.input?.id,
845+
marker: ctx.referentialCascadeDelete,
846+
// ABSENT, not present-but-undefined: the contract is "absent unless true".
847+
hasKey: 'referentialCascadeDelete' in ctx,
848+
hasFieldClear: 'referentialFieldClear' in ctx,
849+
sessionUserId: ctx.session?.userId,
850+
});
851+
const listen = (seen: Cap[], object: string, events: string[]) => {
852+
for (const e of events) engine.on(e, object, (ctx: any) => { seen.push(capture(ctx)); });
853+
};
854+
855+
beforeEach(async () => {
856+
engine = new ObjectQL();
857+
const { driver } = makeStubDriver();
858+
engine.registerDriver(driver, true);
859+
await engine.init();
860+
for (const o of [acct, noteOptional, qline, qlineNote, sideLog]) engine.registry.registerObject(o as any);
861+
});
862+
863+
it('true in BOTH phases of the cascaded child delete, identity inherited; the hand delete of the same child and the root delete carry no key', async () => {
864+
const seen: Cap[] = [];
865+
listen(seen, 'qline', ['beforeDelete', 'afterDelete']);
866+
listen(seen, 'acct', ['beforeDelete', 'afterDelete']);
867+
868+
const a = await engine.insert('acct', { name: 'Acme' });
869+
const l1 = await engine.insert('qline', { title: 'one', parent: a.id });
870+
const l2 = await engine.insert('qline', { title: 'two', parent: a.id });
871+
872+
// The user's hand delete of a line, under the SAME identity.
873+
await engine.delete('qline', { where: { id: l1.id }, context: { ...CALLER } } as any);
874+
const hand = seen.splice(0);
875+
// The engine's cascade when the parent is deleted.
876+
await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any);
877+
const cascade = seen.splice(0);
878+
879+
expect(hand.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']);
880+
for (const o of hand) {
881+
expect(o.marker, 'a hand delete must not read as cascade-issued').toBeUndefined();
882+
expect(o.hasKey, 'the key must be ABSENT on a hand delete, not present-but-undefined').toBe(false);
883+
expect(o.sessionUserId).toBe('u1');
884+
}
885+
886+
const lineDispatches = cascade.filter((o) => o.object === 'qline');
887+
expect(lineDispatches.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']);
888+
for (const o of lineDispatches) {
889+
expect(o.id, 'this really is the cascade removing l2').toBe(l2.id);
890+
expect(o.marker, 'the cascaded child delete carries the declared marker').toBe(true);
891+
// The inherited identity, the thing that erases every other
892+
// discriminator, is present alongside the marker.
893+
expect(o.sessionUserId).toBe('u1');
894+
expect(o.hasFieldClear, 'a cascade child delete is not the update twin').toBe(false);
895+
}
896+
const rootDispatches = cascade.filter((o) => o.object === 'acct');
897+
expect(rootDispatches.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']);
898+
for (const o of rootDispatches) {
899+
expect(o.hasKey, 'the delete the caller asked for is not cascade-issued').toBe(false);
900+
}
901+
expect(await engine.findOne('qline', { where: { id: l2.id } })).toBeNull();
902+
});
903+
904+
it('a grandchild the child cascade removes is cascade-issued too', async () => {
905+
const seen: Cap[] = [];
906+
listen(seen, 'qline_note', ['beforeDelete', 'afterDelete']);
907+
908+
const a = await engine.insert('acct', { name: 'Acme' });
909+
const l = await engine.insert('qline', { title: 'one', parent: a.id });
910+
const n = await engine.insert('qline_note', { body: 'hi', line: l.id });
911+
912+
await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any);
913+
914+
expect(seen.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']);
915+
for (const o of seen) {
916+
expect(o.id).toBe(n.id);
917+
expect(o.marker).toBe(true);
918+
expect(o.sessionUserId).toBe('u1');
919+
}
920+
expect(await engine.findOne('qline_note', { where: { id: n.id } })).toBeNull();
921+
});
922+
923+
it('the set_null cleanup in the same cascade carries referentialFieldClear and NOT this key', async () => {
924+
const seen: Cap[] = [];
925+
listen(seen, 'note', ['beforeUpdate', 'afterUpdate']);
926+
listen(seen, 'qline', ['beforeDelete', 'afterDelete']);
927+
928+
const a = await engine.insert('acct', { name: 'Acme' });
929+
const n = await engine.insert('note', { body: 'hi', account: a.id });
930+
await engine.insert('qline', { title: 'one', parent: a.id });
931+
932+
await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any);
933+
934+
const cleanup = seen.filter((o) => o.object === 'note');
935+
expect(cleanup.map((o) => o.event)).toEqual(['beforeUpdate', 'afterUpdate']);
936+
for (const o of cleanup) {
937+
expect(o.hasFieldClear, 'the cleanup update keeps its own marker').toBe(true);
938+
expect(o.hasKey, 'and does not carry the delete twin').toBe(false);
939+
}
940+
const cascaded = seen.filter((o) => o.object === 'qline');
941+
expect(cascaded.map((o) => o.event)).toEqual(['beforeDelete', 'afterDelete']);
942+
for (const o of cascaded) {
943+
expect(o.marker).toBe(true);
944+
expect(o.hasFieldClear).toBe(false);
945+
}
946+
expect((await engine.findOne('note', { where: { id: n.id } }) as any).account).toBeNull();
947+
});
948+
949+
it("writes a cascaded child's hooks issue through ctx.api are not marked — a delete included, and an update", async () => {
950+
const seen: Cap[] = [];
951+
listen(seen, 'side_log', ['beforeDelete', 'beforeUpdate']);
952+
const lineMarkers: unknown[] = [];
953+
954+
const a = await engine.insert('acct', { name: 'Acme' });
955+
await engine.insert('qline', { title: 'one', parent: a.id });
956+
const s1 = await engine.insert('side_log', { body: 'keep' });
957+
const s2 = await engine.insert('side_log', { body: 'drop' });
958+
959+
engine.on('beforeDelete', 'qline', async (ctx: any) => {
960+
lineMarkers.push(ctx.referentialCascadeDelete);
961+
await ctx.api.object('side_log').deleteById(s2.id);
962+
});
963+
engine.on('afterDelete', 'qline', async (ctx: any) => {
964+
lineMarkers.push(ctx.referentialCascadeDelete);
965+
await ctx.api.object('side_log').update({ id: s1.id, body: 'touched' });
966+
});
967+
968+
await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any);
969+
970+
expect(lineMarkers, 'the hooks ran on the cascaded child delete').toEqual([true, true]);
971+
expect(seen.map((o) => o.event)).toEqual(['beforeDelete', 'beforeUpdate']);
972+
for (const o of seen) {
973+
expect(o.hasKey, `the hook's own ${String(o.event)} is not one the cascade issued`).toBe(false);
974+
// Same identity as the cascade — the hook's writes ride its context.
975+
expect(o.sessionUserId).toBe('u1');
976+
}
977+
expect(await engine.findOne('side_log', { where: { id: s2.id } })).toBeNull();
978+
expect((await engine.findOne('side_log', { where: { id: s1.id } }) as any).body).toBe('touched');
979+
});
980+
981+
it('a delete of the very same row from inside the cascaded dispatch finds the token claimed', async () => {
982+
const markers: Array<{ depth: number; hasKey: boolean }> = [];
983+
let depth = 0;
984+
985+
const a = await engine.insert('acct', { name: 'Acme' });
986+
const l = await engine.insert('qline', { title: 'one', parent: a.id });
987+
988+
engine.on('beforeDelete', 'qline', async (ctx: any) => {
989+
depth += 1;
990+
markers.push({ depth, hasKey: 'referentialCascadeDelete' in ctx });
991+
// Once: the hook's own delete of the row it is being asked about.
992+
if (depth === 1) await ctx.api.object('qline').deleteById(l.id);
993+
});
994+
995+
await engine.delete('acct', { where: { id: a.id }, context: { ...CALLER } } as any);
996+
997+
expect(markers).toEqual([
998+
{ depth: 1, hasKey: true },
999+
{ depth: 2, hasKey: false },
1000+
]);
1001+
});
1002+
1003+
it('an identity-LESS cascade still marks its child, and puts no operation-private key on the envelope', async () => {
1004+
const seen: Cap[] = [];
1005+
listen(seen, 'qline', ['beforeDelete']);
1006+
const childEnvelopeKeys: string[][] = [];
1007+
engine.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
1008+
if (opCtx.operation === 'delete' && opCtx.object === 'qline') {
1009+
childEnvelopeKeys.push(Object.keys(opCtx.context ?? {}));
1010+
}
1011+
await next();
1012+
});
1013+
1014+
const a = await engine.insert('acct', { name: 'Acme' });
1015+
await engine.insert('qline', { title: 'one', parent: a.id });
1016+
await engine.delete('acct', { where: { id: a.id } } as any);
1017+
1018+
expect(seen.length).toBe(1);
1019+
expect(seen[0].marker).toBe(true);
1020+
expect(seen[0].sessionUserId).toBeUndefined();
1021+
// The fact rides no `__` key: there is nothing on the envelope for an
1022+
// app to sniff instead of the declared key.
1023+
expect(childEnvelopeKeys.length).toBe(1);
1024+
expect(childEnvelopeKeys[0].filter((k) => k.startsWith('__'))).toEqual([]);
1025+
});
1026+
});

0 commit comments

Comments
 (0)