Skip to content

Commit 3291213

Browse files
fix(objectql)!: a nested write's stale-roll-up refusal never answers for a delete or transaction that did not land (#29381)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi92cLDdfrzbfFte3TG7C44xC)_ Fixes #29291 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or the hand-written docs teaches what a cascade delete or a rolled-back transaction answers when a dependent's roll-up cannot be recomputed; the two generated reference pages only list the code Narrowing note: a delete or a transaction that answered success after rolling back now refuses (`!` in the title, full dogfood, seat contract review); claim amendment 6577647622 on #29291. ## 维护者速读 - 改了什么:级联删除里,某个子记录的汇总重算失败(`ERR_SUMMARY_RECOMPUTE`,意思是"这次写入已落库,只是汇总没算出来")不再原样冒给父记录删除的调用方;事务因这个错误回滚时也一样。两处都改成一个不带错误码的内部错误(数据入口答 500),原错误挂在 `cause` 上。 - 为什么改:数据入口、MCP、动作/沙箱/流程都把这个错误码当作"写入成功"。原子级联里子记录一失败整个事务回滚,父记录和子记录都还在,可调用方被告知删除成功(主干实测:`DELETE` 答 `200 { success: true }`,记录仍在)。 - 风险与回滚:只在"嵌套写入的汇总失败、外层写入没落地"时改答复;子记录自己的删除、事务里自己捕获后提交、加入外层事务的写入照旧答 `ERR_SUMMARY_RECOMPUTE`。无新错误码、无数据迁移,回滚即整体 revert。 - 先看:`packages/objectql/src/summary-errors.ts`(新增 `unlandedWriteRefusal` 及两个包装);`packages/objectql/src/engine.ts`(`runByIdDelete` / `runPredicateDelete` 的级联调用,以及 `ObjectQL.transaction` 与 `ScopedContext.transaction` 的回滚 catch)。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/engine-cascade-summary-rollback.test.ts`,应看到 7 个测试全部通过;动作入口另见 `packages/runtime/src/action-delete-cascade-summary-rollback.test.ts`(2 个,动作体内 `ctx.engine.delete` 答 400 拒绝而非成功)。 - 需要你做的:无,席位落地。 ## What changes `SummaryRecomputeError` (`ERR_SUMMARY_RECOMPUTE`) means "this write is stored, only a parent roll-up recompute failed", and every answering surface turns it into the write's own success. Two engine units carried a nested write's refusal past the point where it stopped being true. Both now convert it at the engine, so no answering surface changes: - **The cascade walk** (`packages/objectql/src/engine.ts`, the `cascadeDeleteRelations` call in `runByIdDelete` and in `runPredicateDelete`). A dependent's refusal stops the walk before the parent's own row is removed, so it is never the parent delete's answer. It becomes `cascadeStoppedRefusal(e, object)`. This covers every plan: the atomic plan (the owning transaction then rolls back), a joined transaction, the `'split'` plan and a runtime that cannot roll back. - **An owned transaction that rolls back** (`ObjectQL.transaction` and `ScopedContext.transaction`, the catch after the rollback). A refusal escaping the callback is no longer true once every write in it is undone. It becomes `rolledBackRefusal(e)`. - **One meaning, one place** (`packages/objectql/src/summary-errors.ts`). `unlandedWriteRefusal` is the single conversion: a code-less `Error` (an unrecomputable roll-up is an internal failure, so the data doors answer `500 INTERNAL_ERROR`). Its message names the unit that did not complete and the stale summary (`PARENT.FIELD`), and the original refusal is kept as `cause`. Every other error passes through unchanged. It matches by `code`, as every other reader of the refusal does. Nothing new is exported, and there is no new error code or member. - **Unchanged, by design:** the refusal anywhere it is still true. That covers a dependent's own delete, a write outside any unit, a transaction that catches it and commits (the `inAfterAbortUnit` commit-then-rethrow included), and a joined transaction, whose owner decides. - **No source change outside the engine.** PR #29283 (merged as `153869d7`) made the action, sandbox and flow writers answer a landed write's refusal as success; they read the engine's answer, so the conversion reaches them with no edit there. - **Changeset:** `.changeset/29291-cascade-summary-refusal-rolled-back.md`, `@objectstack/objectql` patch, BREAKING (narrowing), `adr-0087: not-required (no-migration-prescription)`. ## Why Measured on `origin/main` `a19759ac` before the fix, with `engine-cascade-summary-rollback.test.ts` (a stub driver that really rolls back; a two-level `master_detail` cascade where each line's delete recomputes a roll-up on another parent, and that parent's update is refused): | case | stored after | caller was told | |---|---|---| | atomic plan, delete the account | account and both lines (rolled back, 1 begin / 1 rollback) | `ERR_SUMMARY_RECOMPUTE`, `written: true` (the line's result) | | runtime that cannot roll back | account and line 2; **line 1 deleted** | `ERR_SUMMARY_RECOMPUTE`, `written: true` | | caller's own `transaction()` around a line delete | both lines (rolled back) | `ERR_SUMMARY_RECOMPUTE`, `written: true` | At the REST door on a real `SqlDriver` (better-sqlite3), `DELETE /api/v1/data/qcr_account/a1` answered `200 {"object":"qcr_account","id":"a1","success":true}` while the account and all three lines were still stored. **The non-atomic plan, as the delta asked.** On a runtime that cannot roll back, the dependent the walk reached first stays deleted (its own write landed) and the parent and every later dependent stay stored. The parent's own write did not land, so the ruling's first sentence covers it without any reading of my own: the parent's caller now gets the same refusal. The message says that dependents written before the stop stay written unless a transaction rolls them back. What lands is unchanged. Whether the walk should instead tolerate a dependent's stale summary and finish the delete is a widening the ruling does not cover; see Acceptance notes. ## Tests Three new files, red first (no existing test was edited; no forced test edits): - `packages/objectql/src/engine-cascade-summary-rollback.test.ts` (7). Refusal pins assert `code` undefined and `cause.code === 'ERR_SUMMARY_RECOMPUTE'`, plus what is stored: - atomic plan by id: refused, 1 rollback, account and lines stored; - a runtime that cannot roll back, by id: refused, account stored, line 1 gone (the measured reading); - the same by predicate (`multi: true`): refused, account stored; - a caller's engine `transaction()` the refusal rolls back: refused, nothing stored; - the same through a scoped context's `transaction()` (`ctx.api`); - CONTROL the line's own delete: `ERR_SUMMARY_RECOMPUTE`, `written: true`, line gone; - CONTROL a caller's transaction that catches the refusal and commits: the refusal stays, 0 rollbacks, line gone. - Red on base: 3 of the first 5 (the predicate and scoped-context cases were added after the fix; each is shown red by its own ablation below). - `packages/rest/src/data-delete-cascade-summary-rollback.test.ts` (2). The composed REST harness of `data-write-summary-recompute-refused.test.ts` on a real `SqlDriver`: `DELETE` of the account answers `500 INTERNAL_ERROR` with the account and all lines stored; CONTROL `DELETE` of a line answers `200 { success: true }` and the line is gone. Red on base (objectql `dist` proven to be main's): 1 of 2, `expected 200 to be 500`. - `packages/runtime/src/action-delete-cascade-summary-rollback.test.ts` (2), added after PR #29283 merged. The real stack of `dispatcher-unique-violation-wire-code.test.ts` (`HttpDispatcher` → `POST /api/v1/actions/:object/:action` → `ctx.engine` → `ObjectQL` → `SqlDriver` on better-sqlite3), same cascade shape: an action whose body runs `ctx.engine.delete('qca_account', 'a1')` answers the action door's `400` refusal (`success: false`, the engine's message), the body's next line never runs, and the account and all three lines are still stored. CONTROL an action deleting a line itself answers `200` with its body's result and the line is gone. Red with the conversion ablated (AB0-action below): `expected 200 to be 400`, body `{"success":true,"data":{"deleted":"a1"}}`, the bug as reported. The door answers `400` because it answers every throw out of an action body that way (a hook's or body's own `throw` is a deliberate 4xx); this PR changes nothing there. <details><summary>Ablations: each mutation landed with <code>ablation-replace</code>, red, restored to blob == HEAD</summary> | # | mutation | file | red | |---|---|---|---| | AB0 | `unlandedWriteRefusal` passes every error through (built; `ablation-dist-preflight` marker present in 4 dist files; restored, rebuilt, `--absent` and tree clean) | `summary-errors.ts` | engine 5 of 7 (every refusal pin, both controls green); REST 1 of 2 | | AB0-action | the same mutation, on `2af3815d` (built; marker present in 4 dist files; restored, rebuilt, `--absent` and tree clean) | `summary-errors.ts` | action pin 1 of 2 (`expected 200 to be 400`); CONTROL green; green again after restore | | AB1 | by-id walk without `cascadeStoppedRefusal` | `engine.ts` | 1 of 7 (no-rollback by id; the atomic case stays green through the transaction conversion) | | AB2 | predicate walk without it | `engine.ts` | 1 of 7 (predicate) | | AB3 | `ObjectQL.transaction` rollback without `rolledBackRefusal` | `engine.ts` | 1 of 7 (caller's transaction) | | AB4 | `ScopedContext.transaction` rollback without it | `engine.ts` | 1 of 7 (scoped context) | AB1 to AB4 ran on `a5bc9c90` plus the predicate pin (committed as the next commit), and AB0 on that commit. The first AB0 attempt used a comment marker, which tsup strips, so the dist preflight could not see it (the red reading was the same). It was rerun with a string-literal marker, and the counts above are from that rerun. </details> ## Acceptance notes - **Declared as narrowing** (the declaration line above; claim amendment 6577647622). A delete whose cascade a dependent's failing roll-up stopped, and a transaction that refusal rolled back, answered success (`ERR_SUMMARY_RECOMPUTE`, read as success by every door) and now answer `500 INTERNAL_ERROR` at the data door and `400` at the action door. The published `@objectstack/objectql` behaves differently on that answer; the data that lands is unchanged. - **The 500 body is the door's sanitised `{"error":"Internal server error","code":"INTERNAL_ERROR"}`.** The message (what did not complete, which summary is stale, the fix) reaches the server log and `cause`. It names the object in double quotes on purpose: a single-quoted object name beside a "not" is read by the data door's unknown-object heuristic (`data-error-classification.ts`) as `404 OBJECT_NOT_FOUND`. The first green leg measured exactly that (`404 OBJECT_NOT_FOUND`), and the quotes were changed before the pin was finalised. - **Out of reach on purpose:** the discrete `beginTransaction` / `commit` / `rollback` trio on `ScopedContext` (the QuickJS sandbox's `ctx.api.transaction`). The caller drives the rollback there, so the engine never sees the refusal leave the unit. Since PR #29283 (merged as `153869d7`), a sandbox body's write already answers its own success inside such a unit. Also unchanged is the degrade path of `transaction()` on a runtime that cannot roll back, where every write before the refusing one, and the refusing one itself, did land. - **Noted, not filed (read-only inference, not measured):** an `after*` hook whose `ctx.api` write raises the refusal. The outer write's abort unit (`inAfterAbortUnit`) catches it, commits and rethrows, so both writes did land and the code is true. The `written` the caller reads, though, is the hook write's result, not the outer write's. - **The non-atomic alternative left open:** tolerating a dependent's stale summary, so that the walk continues and the parent delete lands with `ERR_SUMMARY_RECOMPUTE`, would turn refused deletes into landed ones (widening). The ruling does not cover it, so it is not done here; see `open_questions` in the card report. - **PR #29283 (#28861) merged as `153869d7`** while this PR was open. `main` is merged in (`8fc7f886`) and the one pin through an action surface the seat asked for is added (the runtime file above). - No `packages/spec/**`, `ui/**`, governed or workflow file. ## Verification <details><summary>Suites and typecheck</summary> - `@objectstack/objectql` full local suite on `f1eec6fd` (main merged at `504ba510`): 529 of 529 files, 10266 of 10266 tests; `typecheck` (`tsc --noEmit` + `check:test-typecheck`): exit 0. `os-verify-lock` VERDICT command-exit 0 for the whole chain. - `@objectstack/rest` full local suite on `f1eec6fd`: 368 of 368 files, 6976 passed and 332 skipped; `typecheck`: exit 0. - Final head `2af3815d` (main merged at `8fc7f886`, then the action pin): the three new files re-run green (objectql 7 of 7, rest 2 of 2, runtime 2 of 2), and `@objectstack/runtime` `typecheck` (`tsc --noEmit` + `check:test-typecheck`) exit 0. The objectql and rest full suites above are not re-run: since `f1eec6fd` this branch changed only test files and the changeset, and main's own changes are covered by the dogfood below. - **Full dogfood on the final head `2af3815d`** (rerun, because merging main changed code): closure built (`turbo run build --filter='@objectstack/dogfood^...'`, 68 of 68), then `pnpm --filter @objectstack/dogfood test` under the lock: VERDICT command-exit 0 (34m19s, shared box), Test Files 393 passed and 3 skipped (396), Tests 3243 passed and 28 skipped (3271). The earlier run on `f1eec6fd` was also green (391 files, 3223 tests). </details> <details><summary>Gates</summary> On the final head `2af3815d`, run after the dogfood closure build (unlocked): - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands (the same 63 on `f1eec6fd`, `4dd59050` and `2af3815d`). Each ran with `< /dev/null`. `--ran` reconciles **63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN** (exit 0), and all 63 exit 0. `check:dual-build-cjs-loads` first exited 3 (prerequisite: some package dists missing) and is recorded from its re-run after `turbo run build --filter='./packages/**'` (78 of 78, all cache hits): exit 0. On round 1 the four changeset-reading gates (`check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check:pm-changeset-deadline-census`) were re-run after the last changeset edit, and all exit 0. - First pass, on `f1eec6fd`: two real reds, both in my new test files, fixed in `4dd59050` rather than by any baseline entry. `check:query-options-erasure` (test surface 236 → 237: an `as any` on the REST pin's `engine.find` options; typed now, and the engine pin's `delete` options are no longer cast). `check:rest-log-spy-declared` (the REST pin spied on `console` with no `OS_REST_LOG` level; the spies are removed, since the pin asserts no log). Two prerequisite exit-3s cleared on re-run: `check-plugin-teardown-shape --self-test` after `git fetch --depth=1 origin 621a487…`, and `check:dual-build-cjs-loads` after the build. - `pnpm lint`: exit 0 on `2af3815d` (`eslint . --no-inline-config`). - PR body offline: `check-changeset-no-major --base origin/main --event` (this body and title) exit 0, LEVEL AXIS `no (narrowing)` + `none` at `patch`. `check-closing-keyword-parity --body`: only `Fixes #29291` binds. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (no spec edit) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr)_ <!-- signed: domain:server#3 · os-steve · session_01ADHkmQQoWsHzMSsiaM89mr --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0e0ddac commit 3291213

6 files changed

Lines changed: 618 additions & 8 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql)!: a nested write's stale-roll-up refusal (`ERR_SUMMARY_RECOMPUTE`) never answers for a delete or a transaction that did not land
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
Narrowing note: a delete or a transaction that answered success after rolling back now refuses (`!` in the title, full dogfood, seat contract review); claim amendment 6577647622 on #29291.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no exported declaration is added, removed or narrowed. What narrows is one engine answer: a stale-roll-up refusal that used to leave a delete or a transaction which did not land now leaves it as a code-less error, and the remedy is the summary field's `summaryOperations` or a retry, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
13+
14+
**BREAKING** for one answer only. `ERR_SUMMARY_RECOMPUTE` means "this write is stored, only a parent roll-up recompute failed", and the data doors (REST, MCP) and the action, sandbox and flow writers answer it as the write's own success. Two engine units carried a nested write's refusal past the point where it stopped being true:
15+
16+
- **A cascade delete.** `delete()` removes a record's `master_detail` dependents through the public write path before its own row. When a dependent's delete raised the refusal (its roll-up into another parent could not be recomputed), the cascade stopped before the record was removed, and under the `'atomic'` plan the transaction rolled the dependent back too. The refusal still reached the caller, so `DELETE /api/v1/data/:object/:id` answered `200 { success: true }` for a record that is still stored. On a runtime that cannot roll back, the same delete removed the first dependent and left the record and the rest of its dependents.
17+
- **A transaction that rolls back.** A `transaction()` (on the engine or on a scoped context, `ctx.api`) whose callback let the refusal escape rolled back every write in it, the refusing one included, and the refusal still told the caller it was stored.
18+
19+
**Now.** Both units answer an error with no `code` (an internal failure, a `500` at the data doors), whose message says what did not complete and names the stale summary; the original refusal is kept as `cause`. Nothing else changes: a dependent's own delete, a write outside any unit, a transaction that catches the refusal and commits, and a joined transaction whose owner decides all still read `ERR_SUMMARY_RECOMPUTE` carrying the written result.
20+
21+
**The remedy.** Fix the summary field's `summaryOperations` (the engine's warning names why the recompute was refused), then retry the delete or the transaction.
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A cascade dependent's stale-roll-up refusal never answers for a delete that
5+
* did not land.
6+
*
7+
* `SummaryRecomputeError` (`ERR_SUMMARY_RECOMPUTE`) means "this write is
8+
* stored, only a parent's roll-up recompute failed", and every answering
9+
* surface (the data doors, the action / sandbox / flow writers) turns it into
10+
* the write's own success. A by-id delete walks its `master_detail`
11+
* dependents first, each through the public `delete()`. When one dependent's
12+
* delete raises that refusal (its roll-up into ANOTHER parent cannot be
13+
* recomputed), the walk stops before the parent's own row is removed, so the
14+
* parent delete did not land — under the `'atomic'` plan the transaction then
15+
* rolls the dependent back too. The refusal used to leave the parent's
16+
* `delete()` unchanged, and the caller was told the delete succeeded.
17+
*
18+
* Pinned here: the parent delete refuses with an error that is NOT the
19+
* stale-roll-up refusal, on the atomic plan (nothing stored changes) and on a
20+
* runtime that cannot roll back (the parent is still stored), by id and by
21+
* predicate; a refusal that
22+
* escapes a caller's own `transaction()` (engine or scoped context) that rolls
23+
* back is converted the same way. Controls: a delete of the dependent itself — its own write landed, only
24+
* its recompute failed — still answers `ERR_SUMMARY_RECOMPUTE` carrying its
25+
* result, and so does a caller's transaction that catches it and commits.
26+
*
27+
* The driver rolls back for real (a snapshot per transaction), and refuses
28+
* every write to `bucket` while `refuseBucket` is set: that is the roll-up
29+
* recompute's parent update, refused without a transient message, so no retry
30+
* runs.
31+
*/
32+
33+
import { describe, it, expect } from 'vitest';
34+
import { ObjectQL } from './engine.js';
35+
36+
type Row = Record<string, unknown>;
37+
38+
function matches(row: Row, where: any): boolean {
39+
if (!where || typeof where !== 'object') return true;
40+
return Object.entries(where).every(([k, v]) => {
41+
if (k === '$and') return (v as any[]).every((w) => matches(row, w));
42+
if (k === '$or') return (v as any[]).some((w) => matches(row, w));
43+
if (k.startsWith('$')) return true;
44+
if (v && typeof v === 'object' && !Array.isArray(v)) {
45+
const ops = v as Record<string, unknown>;
46+
if ('$in' in ops) return (ops.$in as unknown[]).map(String).includes(String(row[k]));
47+
if ('$eq' in ops) return String(row[k]) === String(ops.$eq);
48+
if ('$ne' in ops) return String(row[k]) !== String(ops.$ne);
49+
return true;
50+
}
51+
return row[k] === v || (row[k] != null && v != null && String(row[k]) === String(v));
52+
});
53+
}
54+
55+
function makeDriver(transactional: boolean) {
56+
let stores = new Map<string, Map<string, Row>>();
57+
const storeFor = (o: string) => stores.get(o) ?? (stores.set(o, new Map()), stores.get(o)!);
58+
const begun: unknown[] = [];
59+
const rolledBack: unknown[] = [];
60+
const snapshots = new Map<unknown, typeof stores>();
61+
const state = { refuseBucket: false };
62+
let n = 0;
63+
const driver: any = {
64+
name: 'primary', version: '0.0.0', supports: {}, begun, rolledBack, state,
65+
rowsOf: (o: string) => Array.from(storeFor(o).values()),
66+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
67+
async syncSchema() {},
68+
async find(o: string, ast: any) {
69+
const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where));
70+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
71+
},
72+
async findOne(o: string, ast: any) { return (await this.find(o, ast))[0] ?? null; },
73+
async create(o: string, data: Row) {
74+
const row = { ...data, id: (data.id as string) ?? `${o}_${++n}` };
75+
storeFor(o).set(String(row.id), row);
76+
return row;
77+
},
78+
async update(o: string, id: string, data: Row) {
79+
if (state.refuseBucket && o === 'bucket') throw new Error('bucket store refused the write');
80+
const row = { ...storeFor(o).get(String(id)), ...data, id };
81+
storeFor(o).set(String(id), row);
82+
return row;
83+
},
84+
async delete(o: string, id: string) { return storeFor(o).delete(String(id)); },
85+
async deleteMany(o: string, ast: any) {
86+
const doomed = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where));
87+
for (const r of doomed) storeFor(o).delete(String(r.id));
88+
return doomed.length;
89+
},
90+
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
91+
async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
92+
async bulkUpdate() { return []; }, async bulkDelete() {},
93+
};
94+
if (transactional) {
95+
driver.beginTransaction = async () => {
96+
const handle = { trx: begun.length + 1 };
97+
snapshots.set(handle, new Map(Array.from(stores, ([o, s]) => [o, new Map(Array.from(s, ([k, v]) => [k, { ...v }]))])));
98+
begun.push(handle);
99+
return handle;
100+
};
101+
driver.commit = async (h: unknown) => { snapshots.delete(h); };
102+
driver.rollback = async (h: unknown) => { stores = snapshots.get(h) ?? stores; rolledBack.push(h); };
103+
}
104+
return driver;
105+
}
106+
107+
async function makeEngine(transactional: boolean) {
108+
const log = () => {};
109+
const engine = new ObjectQL({ logger: { debug: log, info: log, warn: log, error: log } } as any);
110+
const driver = makeDriver(transactional);
111+
engine.registerDriver(driver, true);
112+
await engine.init();
113+
const text = (name: string) => ({ name, type: 'text' });
114+
engine.registry.registerObject({ name: 'acct', label: 'acct', fields: { id: { ...text('id'), primaryKey: true }, name: text('name') } } as any, '__test__');
115+
engine.registry.registerObject({
116+
name: 'bucket', label: 'bucket',
117+
fields: {
118+
id: { ...text('id'), primaryKey: true }, name: text('name'),
119+
line_count: { name: 'line_count', type: 'summary', summaryOperations: { object: 'line', function: 'count' } },
120+
},
121+
} as any, '__test__');
122+
engine.registry.registerObject({
123+
name: 'line', label: 'line',
124+
fields: {
125+
id: { ...text('id'), primaryKey: true }, name: text('name'),
126+
acct: { name: 'acct', type: 'master_detail', reference: 'acct', deleteBehavior: 'cascade' },
127+
bucket: { name: 'bucket', type: 'lookup', reference: 'bucket' },
128+
},
129+
} as any, '__test__');
130+
await engine.insert('acct', { id: 'a1', name: 'A' });
131+
await engine.insert('bucket', { id: 'b1', name: 'B' });
132+
await engine.insert('line', { id: 'l1', name: 'L1', acct: 'a1', bucket: 'b1' });
133+
await engine.insert('line', { id: 'l2', name: 'L2', acct: 'a1', bucket: 'b1' });
134+
driver.begun.length = 0;
135+
driver.rolledBack.length = 0;
136+
driver.state.refuseBucket = true;
137+
return { engine, driver };
138+
}
139+
140+
const ids = (rows: Row[]) => rows.map((r) => r.id).sort();
141+
142+
describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a parent delete that did not land', () => {
143+
it('atomic plan: the parent delete refuses with another error, and nothing stored changes', async () => {
144+
const { engine, driver } = await makeEngine(true);
145+
const err: any = await engine.delete('acct', { where: { id: 'a1' } }).then(() => null, (e: unknown) => e);
146+
expect(err).toBeInstanceOf(Error);
147+
expect(err.code).toBeUndefined();
148+
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
149+
expect(err.written).toBeUndefined();
150+
expect(driver.rolledBack).toHaveLength(1);
151+
expect(ids(driver.rowsOf('acct'))).toEqual(['a1']);
152+
expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']);
153+
});
154+
155+
it('a runtime that cannot roll back: the parent is still stored, and the caller is refused', async () => {
156+
const { engine, driver } = await makeEngine(false);
157+
const err: any = await engine.delete('acct', { where: { id: 'a1' } }).then(() => null, (e: unknown) => e);
158+
expect(err).toBeInstanceOf(Error);
159+
expect(err.code).toBeUndefined();
160+
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
161+
expect(ids(driver.rowsOf('acct'))).toEqual(['a1']);
162+
// The dependent the walk reached first is gone: nothing can roll it back.
163+
expect(ids(driver.rowsOf('line'))).toEqual(['l2']);
164+
});
165+
166+
it('a predicate delete (multi) on a runtime that cannot roll back: refused the same way, the parent still stored', async () => {
167+
const { engine, driver } = await makeEngine(false);
168+
const err: any = await engine.delete('acct', { where: { name: 'A' }, multi: true }).then(() => null, (e: unknown) => e);
169+
expect(err).toBeInstanceOf(Error);
170+
expect(err.code).toBeUndefined();
171+
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
172+
expect(ids(driver.rowsOf('acct'))).toEqual(['a1']);
173+
});
174+
175+
it('a caller\'s own transaction that the refusal rolls back: refused with another error, nothing stored', async () => {
176+
const { engine, driver } = await makeEngine(true);
177+
const err: any = await engine
178+
.transaction(async (trx: any) => engine.delete('line', { where: { id: 'l1' }, context: trx }))
179+
.then(() => null, (e: unknown) => e);
180+
expect(err).toBeInstanceOf(Error);
181+
expect(err.code).toBeUndefined();
182+
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
183+
expect(driver.rolledBack).toHaveLength(1);
184+
expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']);
185+
});
186+
187+
it('the same through a scoped context\'s transaction (ctx.api)', async () => {
188+
const { engine, driver } = await makeEngine(true);
189+
const scoped = (engine as any).createContext({ isSystem: true });
190+
const err: any = await scoped
191+
.transaction(async (trx: any) => trx.object('line').deleteById('l1'))
192+
.then(() => null, (e: unknown) => e);
193+
expect(err).toBeInstanceOf(Error);
194+
expect(err.code).toBeUndefined();
195+
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
196+
expect(driver.rolledBack).toHaveLength(1);
197+
expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']);
198+
});
199+
200+
it('CONTROL the dependent\'s own delete landed: ERR_SUMMARY_RECOMPUTE, carrying its result', async () => {
201+
const { engine, driver } = await makeEngine(true);
202+
const err: any = await engine.delete('line', { where: { id: 'l1' } }).then(() => null, (e: unknown) => e);
203+
expect(err?.code).toBe('ERR_SUMMARY_RECOMPUTE');
204+
expect(err.written).toBe(true);
205+
expect(ids(driver.rowsOf('line'))).toEqual(['l2']);
206+
});
207+
208+
it('CONTROL a caller\'s transaction that catches the refusal commits, and the refusal stays true', async () => {
209+
const { engine, driver } = await makeEngine(true);
210+
let caught: any;
211+
await engine.transaction(async (trx: any) => {
212+
await engine.delete('line', { where: { id: 'l1' }, context: trx }).catch((e: unknown) => { caught = e; });
213+
});
214+
expect(caught?.code).toBe('ERR_SUMMARY_RECOMPUTE');
215+
expect(driver.rolledBack).toHaveLength(0);
216+
expect(ids(driver.rowsOf('line'))).toEqual(['l2']);
217+
});
218+
});

‎packages/objectql/src/engine.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ import {
201201
} from '@objectstack/core';
202202
import { WriteEpoch, isWriteEpochOperation } from './write-epoch.js';
203203
import { bridgeAuthzInvalidation } from './authz-invalidation-bridge.js';
204-
import { SummaryRecomputeError, type SummaryRecomputeFailure } from './summary-errors.js';
204+
import { SummaryRecomputeError, cascadeStoppedRefusal, rolledBackRefusal, type SummaryRecomputeFailure } from './summary-errors.js';
205205
import { TransactionUnsupportedError } from './transaction-errors.js';
206206
import {
207207
aggregateSummaryValue,
@@ -20968,8 +20968,13 @@ export class ObjectQL implements IObjectQLEngine {
2096820968
// wrapping its cascade. `buildDriverOptions` only fills keys that
2096920969
// are still `undefined`, so re-running it adds the handle and
2097020970
// changes nothing else.
20971+
//
20972+
// A dependent's stale-roll-up refusal stops the walk before this
20973+
// row is removed, so it is not this delete's answer:
20974+
// `cascadeStoppedRefusal` says what it is instead.
2097120975
const runByIdDelete = async (writeContext?: ExecutionContext) => {
20972-
await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext);
20976+
await this.cascadeDeleteRelations(object, [hookContext.input.id as string | number], writeContext)
20977+
.catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); });
2097320978
return await driver.delete(
2097420979
object,
2097520980
hookContext.input.id as string,
@@ -21061,8 +21066,11 @@ export class ObjectQL implements IObjectQLEngine {
2106121066
const matchedIds = matched.map((row) => row?.id).filter((v): v is string | number => v != null);
2106221067
if (summaryKeyColumns.length > 0) summaryPriorRows = withoutDeletedSelfParents(object, matched, matchedIds, this.getSummaryDescriptors(object));
2106321068
deletedSecrets = deletedSecretSlots(deleteSchema, matched);
21064-
// [#27004] One walk, rooted at the whole matched set.
21065-
await this.cascadeDeleteRelations(object, matchedIds, writeContext);
21069+
// [#27004] One walk, rooted at the whole matched set. A
21070+
// dependent's stale-roll-up refusal is not this delete's
21071+
// answer, as in the by-id branch.
21072+
await this.cascadeDeleteRelations(object, matchedIds, writeContext)
21073+
.catch((e: unknown) => { throw cascadeStoppedRefusal(e, object); });
2106621074
// The id set rides an `$in`; batched so one statement never
2106721075
// outgrows a driver's bind-parameter limit.
2106821076
const batchSize = ObjectQL.REFERENCE_PROBE_BATCH;
@@ -21915,8 +21923,9 @@ export class ObjectQL implements IObjectQLEngine {
2191521923
// [#21274] A boundary outside the middleware seam: a commit failure, or
2191621924
// a callback that reached the driver through the transaction handle.
2191721925
// An error that already crossed an engine boundary inside the callback
21918-
// comes back as the same reference.
21919-
throw redactPropagatedDriverFault(err);
21926+
// comes back as the same reference. A stale-roll-up refusal ("stored")
21927+
// is no longer true once this rollback has run: `rolledBackRefusal`.
21928+
throw redactPropagatedDriverFault(rolledBackRefusal(err));
2192021929
}
2192121930
}
2192221931

@@ -22906,8 +22915,9 @@ export class ScopedContext implements IScopedContext, RunAsDerivableApi {
2290622915
} catch (error) {
2290722916
if (driver.rollback) await driver.rollback(trx);
2290822917
else if (driver.rollbackTransaction) await driver.rollbackTransaction(trx);
22909-
// [#21274] The engine surface's twin (`ObjectQL.transaction`).
22910-
throw redactPropagatedDriverFault(error);
22918+
// [#21274] The engine surface's twin (`ObjectQL.transaction`), the
22919+
// stale-roll-up conversion included.
22920+
throw redactPropagatedDriverFault(rolledBackRefusal(error));
2291122921
}
2291222922
}
2291322923

0 commit comments

Comments
 (0)