Skip to content

Commit 2af3815

Browse files
committed
test(runtime): pin an action's ctx.engine.delete over a rolled-back cascade as a refusal; Clause-② reads no (narrowing)
An action body's ctx.engine.delete (PR #29283 answers a landed write's stale-roll-up refusal as success) whose master_detail cascade a dependent's failing roll-up stopped now answers the action door's 400 refusal, the body does not run on, and nothing is deleted. Ablating the engine conversion answers 200 {success:true,data:{deleted:'a1'}} for a delete that rolled back. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmQQoWsHzMSsiaM89mr
1 parent 8fc7f88 commit 2af3815

2 files changed

Lines changed: 156 additions & 1 deletion

File tree

‎.changeset/29291-cascade-summary-refusal-rolled-back.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
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
66

7-
Clause-②: yes (narrowing) — a delete that answered success after rolling back now refuses (`!` in the title, full dogfood, seat contract review)
7+
Clause-②: no (narrowing)
88
Metadata-protocol: none
99

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+
1012
<!-- 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). -->
1113

1214
**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:
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* An action body's `ctx.engine.delete` whose `master_detail` cascade is
5+
* stopped by a dependent's failing roll-up is refused, never answered as the
6+
* success of a delete that rolled back.
7+
*
8+
* `ctx.engine` answers a write's own success over the engine's stale-roll-up
9+
* refusal (`ERR_SUMMARY_RECOMPUTE`, "the triggering records WERE written"), so
10+
* a body runs on after a write that landed. Deleting an account cascades to its
11+
* lines under the `'atomic'` plan. Each line's delete recomputes a roll-up on
12+
* ANOTHER parent (`qca_bucket.memo_sum`, a sum over a text field, which every
13+
* recompute refuses), so the first line's refusal stops the cascade and the
14+
* transaction rolls everything back. The engine now answers that delete with a
15+
* refusal that is not the stale-roll-up one, so the body does not run on and
16+
* the action door answers it as it answers any other throw out of a body: a
17+
* `400` refusal carrying the engine's message, `success: false`.
18+
*
19+
* Pinned through the real stack: `HttpDispatcher` → the action door →
20+
* `ctx.engine` → `ObjectQL` → a real `SqlDriver` on better-sqlite3, which rolls
21+
* back for real (the harness of `dispatcher-unique-violation-wire-code.test.ts`).
22+
*
23+
* - the account's delete: the action answers `400`, the body's next line never
24+
* ran, and the account and its lines are all still stored;
25+
* - CONTROL a line's own delete landed: the action answers its body's own
26+
* result and the line is gone.
27+
*/
28+
29+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
30+
import { ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
33+
import { HttpDispatcher } from './http-dispatcher.js';
34+
import { installDefaultOrganization } from './default-organization.testkit.js';
35+
36+
const PREFIX = '/api/v1';
37+
const TEST_PACKAGE_ID = 'objectstack-test';
38+
const ACCOUNT = 'qca_account';
39+
const BUCKET = 'qca_bucket';
40+
const LINE = 'qca_line';
41+
42+
const ACCOUNT_OBJECT = {
43+
name: ACCOUNT,
44+
label: 'Account',
45+
fields: {
46+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
47+
name: { name: 'name', type: 'text' as const, label: 'Name' },
48+
},
49+
actions: [
50+
{ name: 'delete_account', label: 'Delete account', type: 'script', target: 'delete_account' },
51+
{ name: 'delete_line', label: 'Delete line', type: 'script', target: 'delete_line' },
52+
],
53+
};
54+
55+
const BUCKET_OBJECT = {
56+
name: BUCKET,
57+
label: 'Bucket',
58+
fields: {
59+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
60+
name: { name: 'name', type: 'text' as const, label: 'Name' },
61+
// A sum over a text field: every recompute of it is refused.
62+
memo_sum: { name: 'memo_sum', type: 'summary' as const, label: 'Memo sum', summaryOperations: { object: LINE, function: 'sum', field: 'memo' } },
63+
},
64+
};
65+
66+
const LINE_OBJECT = {
67+
name: LINE,
68+
label: 'Line',
69+
fields: {
70+
id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' },
71+
name: { name: 'name', type: 'text' as const, label: 'Name' },
72+
memo: { name: 'memo', type: 'text' as const, label: 'Memo' },
73+
account: { name: 'account', type: 'master_detail' as const, reference: ACCOUNT, deleteBehavior: 'cascade', label: 'Account' },
74+
bucket: { name: 'bucket', type: 'lookup' as const, reference: BUCKET, label: 'Bucket' },
75+
},
76+
};
77+
78+
let engine: ObjectQL;
79+
let dispatcher: HttpDispatcher;
80+
const bodyRanOn: string[] = [];
81+
82+
afterAll(async () => {
83+
try { await engine?.destroy(); } catch { /* noop */ }
84+
});
85+
86+
beforeAll(async () => {
87+
engine = new ObjectQL();
88+
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
89+
await engine.init();
90+
for (const o of [ACCOUNT_OBJECT, BUCKET_OBJECT, LINE_OBJECT]) engine.registry.registerObject(o as any, TEST_PACKAGE_ID);
91+
await engine.syncSchemas();
92+
await installDefaultOrganization(engine);
93+
await engine.insert(ACCOUNT, { id: 'a1', name: 'A' });
94+
await engine.insert(BUCKET, { id: 'b1', name: 'B' });
95+
for (const id of ['l1', 'l2', 'l3']) {
96+
await engine.insert(LINE, { id, name: id, account: 'a1', bucket: 'b1' }).catch((e: any) => {
97+
// The seed itself meets the failing roll-up; its rows are written.
98+
if (e?.code !== 'ERR_SUMMARY_RECOMPUTE') throw e;
99+
});
100+
}
101+
const ql = engine as any;
102+
ql.registerAction(ACCOUNT, 'delete_account', async (ctx: any) => {
103+
await ctx.engine.delete(ACCOUNT, 'a1');
104+
bodyRanOn.push('delete_account');
105+
return { deleted: 'a1' };
106+
});
107+
ql.registerAction(ACCOUNT, 'delete_line', async (ctx: any) => {
108+
await ctx.engine.delete(LINE, 'l3');
109+
bodyRanOn.push('delete_line');
110+
return { deleted: 'l3' };
111+
});
112+
const protocol = new ObjectStackProtocolImplementation(engine as any);
113+
const services: Record<string, unknown> = { objectql: engine, data: engine, protocol };
114+
const get = (n: string) => services[n] ?? null;
115+
const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) };
116+
dispatcher = new HttpDispatcher(kernel);
117+
(dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'u_member', isSystem: false });
118+
}, 60_000);
119+
120+
/** `POST /api/v1/actions/<rest>` as `@objectstack/hono`'s catch-all hands it to `dispatch()`. */
121+
async function postAction(rest: string) {
122+
const path = `/actions/${rest}`;
123+
const request = new Request(`http://localhost${PREFIX}${path}`, {
124+
method: 'POST',
125+
headers: { 'content-type': 'application/json' },
126+
body: '{}',
127+
});
128+
const res: any = await dispatcher.dispatch('POST', path, {}, {}, { request } as any, PREFIX);
129+
return res.response as { status: number; body: any };
130+
}
131+
132+
const rowIds = async (object: string) =>
133+
((await engine.find(object, { fields: ['id'] })) as Array<{ id: string }>).map((r) => r.id).sort();
134+
135+
describe('an action\'s ctx.engine.delete whose cascade a dependent\'s failing roll-up stops is refused', () => {
136+
it('the account\'s delete: the action answers a 400 refusal, the body stopped there, and nothing is deleted', async () => {
137+
const response = await postAction(`${ACCOUNT}/delete_account`);
138+
expect(response.status, JSON.stringify(response.body)).toBe(400);
139+
expect(response.body?.success, JSON.stringify(response.body)).toBe(false);
140+
expect(response.body?.error?.message).toContain('The delete of "qca_account" did not complete');
141+
expect(bodyRanOn).not.toContain('delete_account');
142+
expect(await rowIds(ACCOUNT)).toEqual(['a1']);
143+
expect(await rowIds(LINE)).toEqual(['l1', 'l2', 'l3']);
144+
});
145+
146+
it('CONTROL a line\'s own delete landed: the action answers its body\'s result, the line is gone', async () => {
147+
const response = await postAction(`${ACCOUNT}/delete_line`);
148+
expect(response.status, JSON.stringify(response.body)).toBe(200);
149+
expect(JSON.stringify(response.body)).toContain('"deleted":"l3"');
150+
expect(bodyRanOn).toContain('delete_line');
151+
expect(await rowIds(LINE)).toEqual(['l1', 'l2']);
152+
});
153+
});

0 commit comments

Comments
 (0)