Skip to content

Commit f966f01

Browse files
fix(objectql)!: a hook's own write's stale-roll-up refusal never answers for the outer write; the unlanded-write refusal re-leads, declares 500, and never claims a failed rollback (#29519)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Dsf26PgoqraAnprbfyBEiC)_ Fixes #29422 Fixes #29421 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` or the hand-written docs teaches what a write answers when a hook's own write, a cascade or a transaction meets a roll-up that cannot be recomputed; the generated reference pages only list the code Narrowing note: the seat amended the claim from `Clause-②: no` once the fix shape was chosen (option A on #29422, option B on #29421 item 2). A write whose hook let its own write's stale-roll-up refusal escape answered success with the hook's record. It now refuses, and an aborting `after*` hook's unit rolls both rows back. The action door also answers these refusals `500` instead of `400`. So the title carries `!`, full dogfood ran locally, and the seat runs a contract review at tier. ## 维护者速读 - 改了什么:钩子(hook)通过 `ctx.api` 自己写的记录如果汇总重算失败(`ERR_SUMMARY_RECOMPUTE`),这个错误不再冒给外层写入的调用方当作"外层写入成功"。外层写入现在报内部错误(500 INTERNAL_ERROR),文案点名外层写入和钩子,原错误挂在 `cause` 上。另外三处顺带修正:级联多层时文案点名最外层对象;这类错误显式声明 `status: 500`、`code: INTERNAL_ERROR`;事务回滚本身失败时不再说"已回滚"。 - 为什么改:主干实测 `POST /api/v1/data/case` 答 `201`,可返回的 id 和记录是钩子写的那条 line,不是 case。带 abort 钩子时整个单元还会把两行都提交。 - 风险与回滚:只改"嵌套写入的汇总失败、外层写入因此没完成"这一类答复。其它钩子错误照旧;`onError: 'log'` 的钩子照旧不让外层失败;外层写入自己的汇总失败照旧答 `ERR_SUMMARY_RECOMPUTE`。动作入口对这类错误从 400 改为 500,数据入口的响应体字节不变。没有新错误码,没有数据迁移,回滚就是整体 revert。 - 先看:`packages/objectql/src/summary-errors.ts`(`hookStoppedRefusal`、`UnlandedWriteRefusal` 类与重新领头、`rollbackFailedRefusal`);`packages/objectql/src/engine.ts` 的 `triggerHooks` 和 `dispatchUnscopedMultiWriteHooks` 两处 handler 调用,以及 `ObjectQL.transaction` 的回滚 catch。 - 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run src/engine-hook-stale-summary-refusal.test.ts src/engine-unlanded-refusal-lead.test.ts`,应看到 14 个测试全部通过。 - 需要你做的:无,席位落地(契约复核后)。 ## What changes **#29422: a hook's own write.** The one seam a hook handler's error leaves through is `triggerHooks` (`await entry.handler(ctx)`); the unscoped multi-write dispatch is its twin. At that seam a stale-roll-up refusal is converted with `hookStoppedRefusal`. Before, the refusal crossed it carrying the HOOK's write as `written`, so the outer door answered the hook's record as its own result. Now the outer write refuses with an internal failure that names it and the hook (`The insert of "case" did not complete: its afterInsert hook "line_writer" stopped. …`), and the refusal is kept as `cause`. Consequences: - An aborting `after*` hook's unit (`inAfterAbortUnit`) no longer sees `SummaryRecomputeError`, so it rolls both rows back, as `HookSchema.onError` `abort` says. Only the outer write's OWN refusal still reaches it and commits. - With no unit (a code-registered hook, a driver without transactions, a caller's unit), the text says that what was written before that point stays written. - `before*` hooks go through the same seam: the outer write never ran. - Bounds the seat set: only the `ERR_SUMMARY_RECOMPUTE` family is converted, and every other hook error passes through unchanged (pinned). A hook declared `onError: 'log'` swallows it before the seam, so the outer write still succeeds (pinned). **#29421: the unlanded-write refusal** (`summary-errors.ts`): 1. **Lead.** The converted error is now a module-private class, `UnlandedWriteRefusal`. When a cascade walk or a hook meets an earlier conversion, it re-leads with its own write and keeps the original refusal as `cause`. A three-level cascade's caller reads the root object. A transaction does not re-lead: what escapes it already names the write that did not complete. 2. **Structural status.** The class declares `status: 500` and `code: 'INTERNAL_ERROR'`. The data door's body is byte-identical (`500 { error: "Internal server error", code: "INTERNAL_ERROR" }`), now declared instead of reached by falling through every heuristic. The action door now answers `500 INTERNAL_ERROR` instead of `400`, with the same sentence. `status` alone was measured and rejected: a declared 5xx with no code drops `code` from the data door's body. 3. **Rollback failure.** When `ObjectQL.transaction`'s rollback throws, the refusal is `rollbackFailedRefusal` ("…its rollback failed too, so what was written inside it may or may not be stored"), not "rolled back". `ScopedContext.transaction` does not swallow a rollback failure, so it is unchanged. ## Tests - New `packages/objectql/src/engine-hook-stale-summary-refusal.test.ts` (7): aborting `afterInsert`, `afterUpdate` and `afterDelete` (refused, rolled back, nothing stored); a code-registered hook with no unit (refused with the hedged text, both rows stored); `beforeInsert`; and three CONTROLS: `onError: 'log'` succeeds, the outer write's own refusal is still `ERR_SUMMARY_RECOMPUTE` and commits, and another hook error passes through unchanged. - New `packages/objectql/src/engine-unlanded-refusal-lead.test.ts` (7): a three-level cascade where the root's caller reads the root; the middle level's own caller; re-lead at every depth through cascade and hook seams with `cause` kept; the rollback-throws hedge. CONTROLS: an unrelated code-less error, the leaf's own refusal, and a successful rollback. - New `packages/rest/src/data-write-hook-stale-summary-refusal.test.ts` (3, real SqlDriver): POST with a metadata-bound hook answers `500` with the exact body and stores neither row; POST with a code hook answers the same and stores both rows; CONTROL a line's own POST answers `201` with its own row. - **Forced test edits** (named): `packages/objectql/src/engine-cascade-summary-rollback.test.ts` (#29381's pin), where five `err.code` assertions go from undefined to `'INTERNAL_ERROR'` plus `status` 500; `packages/runtime/src/action-delete-cascade-summary-rollback.test.ts`, where the action door goes 400 → 500 with `code: INTERNAL_ERROR` (the seat accepted this); and `packages/rest/src/data-delete-cascade-summary-rollback.test.ts`, tightened from status + code to the whole body (the byte-identity pin the seat asked for). ## Acceptance notes - The pending #29381 changeset says the converted error has "no `code`". This PR's changeset says it supersedes that sentence; the #29381 changeset was not edited. - `ui/**` and `packages/client` were grepped for a branch on an action's `400`. None reads it for these refusals. `classifyLoadError` is for data fetches, `flowResponse.ts` keys on `FLOW_FAILED`, and the analytics `400` arm is another door. - Commit shape: one fix commit per card (61f49fa #29422, c594fd0 #29421), plus one REST door-pin commit (54eb565), the changeset commit and the main merge. ## Verification All readings on `objectstack-ai/objectstack` at head `b81dcfa4` (the branch's three commits plus the changeset, merged with origin/main `04e0c2c0`), after `pnpm install --frozen-lockfile` and a full `turbo run build --filter='./packages/**'` (78/78). <details><summary>Measurement on main before the fix (#29422)</summary> Harness: RestServer → protocol → ObjectQL → SqlDriver (better-sqlite3), on main `8ca9dd7b`, with a scratch test that was not committed. An `afterInsert` hook on `case` writes a `line` through `ctx.api`, and the line's roll-up refuses. - `POST /data/case` with a metadata-bound hook answered `201 {object:"case", id: LINE_ID, record: LINE_ROW}`, where LINE_ID and LINE_ROW are the id and row of the line the hook wrote (name "from-hook"). Both rows were stored, because the unit committed. - `POST /data/codecase` with a code-registered hook (no unit) gave the same shape. Both rows were stored. - The engine's read: `code ERR_SUMMARY_RECOMPUTE`, `written` = the line row, failures `line -> bucket.memo_sum`. - CONTROL `POST /data/line` answered `201` with its own row. </details> <details><summary>Item 2: door readings for the three shapes of the converted error</summary> | declares | data door `DELETE` | action door `POST` | |---|---|---| | nothing (#29381 shape) | 500 `{error, code: INTERNAL_ERROR}` | 400 `{error:{code: VALIDATION_ERROR, message, httpStatus:400}}` | | `status: 500` only | 500 `{error}`, with `code` dropped | 500 `INTERNAL_ERROR` + sentence | | `status: 500` + `code: INTERNAL_ERROR` (this PR) | 500 `{error, code: INTERNAL_ERROR}`, byte-identical | 500 `INTERNAL_ERROR` + sentence | </details> <details><summary>Tests</summary> - `pnpm --filter @objectstack/objectql test && pnpm --filter @objectstack/objectql typecheck` (under the lock): VERDICT command-exit 0. Test Files 536 passed (536), Tests 10398 passed (10398); typecheck exit 0. - rest `src/data-delete-cascade-summary-rollback.test.ts src/data-write-hook-stale-summary-refusal.test.ts src/data-write-summary-recompute-refused.test.ts`: 3 files, 10 tests passed. runtime `src/action-delete-cascade-summary-rollback.test.ts`: 2 tests passed. - Full dogfood, `pnpm --filter @objectstack/dogfood test` under the lock, with no shard and no path: VERDICT command-exit 0. Test Files 397 passed | 3 skipped (400), Tests 3277 passed | 28 skipped (3305). - Lint: root eslint `--no-inline-config` on `packages/objectql/src` plus the three door test files: exit 0, no output. </details> <details><summary>Ablations (each restored, with git diff HEAD empty, by scripts/ablation-replace.mjs)</summary> - **Hook seam** (`triggerHooks`: `throw hookStoppedRefusal(…)` → `throw e`), engine pin: 4 failed | 3 passed. `AssertionError: expected 'ERR_SUMMARY_RECOMPUTE' to be undefined` on the three after* cases, the code-hook case and `beforeInsert`. The three controls stay green. - **Re-lead** (`relead && e instanceof UnlandedWriteRefusal ? e.cause : e` → `e`): 2 failed | 5 passed. The three-level case reads `The delete of "line" did not complete…`. - **Rollback hedge** (`rollbackFailed ? rollbackFailedRefusal(err) : rolledBackRefusal(err)` → `rolledBackRefusal(err)`): 1 failed | 6 passed. It reads `The transaction rolled back, so nothing written inside it was stored.` - **Declared code dropped** (status kept), objectql dist rebuilt, with `ablation-dist-preflight` showing the marker present: data-door pins 3 failed (`expected { error: 'Internal server error' } to deeply equal { error, code }`); the action pin stays green. - **Status and code both dropped** (the #29381 shape), dist rebuilt, marker present: data-door pins 5/5 green, which shows the body is byte-identical to the old heuristic path. Action pin: 1 failed, `expected 400 to be 500`, body code `VALIDATION_ERROR`. - Restore leg: objectql dist rebuilt, and `ablation-dist-preflight --absent` passed for both markers. </details> <details><summary>Gates: dispatch-gates --commands, 63 derived</summary> - All 63 ran; `--ran` reconciliation: "63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED". - Two first-pass exit 3s were re-run green. `check-plugin-teardown-shape --self-test` needed its pinned fixture commit fetched. `check:type-check-debt` had queued behind this branch's own dogfood lock past its budget; on the re-run it was OK, 0 raw errors. - CI families: see this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmPCSqt8uDw17RmX9hu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd68248 commit f966f01

9 files changed

Lines changed: 754 additions & 29 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 hook's own write's stale-roll-up refusal (`ERR_SUMMARY_RECOMPUTE`) never answers for the write the hook is bound to, and the unlanded-write refusal leads with the outer write and declares `500 INTERNAL_ERROR`
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
Narrowing note: a write whose hook let its own write's stale-roll-up refusal escape used to answer success with the hook's record. It now refuses, and an aborting `after*` hook's unit rolls both rows back. The action door also answers these refusals `500` instead of `400`. Hence the `!` in the title, the full dogfood run and the seat's contract review. The claim was amended by seat 5 on #29422.
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 engine answers: a stale-roll-up refusal that a hook's own write raised now leaves the outer write as an internal-failure refusal, and the refusal a write answers when a nested write's refusal stopped it now declares status 500 and code INTERNAL_ERROR. 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 these answers only. `ERR_SUMMARY_RECOMPUTE` means "this write is stored, only a parent roll-up recompute failed". The data doors (REST, MCP) and the action, sandbox and flow writers answer it as the write's own success, using the refusal's `written` as the result.
15+
16+
- **A hook's own write.** Suppose a hook writes through `ctx.api` and lets that write's refusal escape. The refusal reached the caller of the write the hook is bound to, carrying the HOOK's record. So `POST /api/v1/data/:object` answered `201` with the id and record of the row the hook wrote. Under an aborting `after*` hook the write's own unit then committed both rows. Now the refusal is converted where it leaves the handler, for `before*` and `after*` hooks alike, and for `beforeFind` / `afterFind` too: a find hook whose own write refuses now fails the read with the same declared `500` (the read failed before as well, only its sentence and status were inferred). The write refuses with an internal failure that names it, the hook and the stale summary, and keeps the original refusal as `cause`. An aborting `after*` hook's unit rolls both rows back, as `HookSchema.onError` `abort` says. With no unit (a code-registered hook, a driver without transactions, a caller's own unit), the message says that what was written before that point stays written. Every other error a hook throws passes through unchanged, and a hook declared `onError: 'log'` still does not fail the write.
17+
- **The refusal's lead on a nested cascade.** When a grandchild's delete refused, the parent's caller read `The delete of "<child>" did not complete`. Each outer cascade walk and each hook now re-leads the refusal, so the caller reads the write it asked for. `cause` stays the original refusal at every depth.
18+
- **A declared status.** The refusal a write answers when a nested write's refusal stopped it (a cascade delete, a rolled-back transaction, and now a hook) declares `status: 500` and `code: 'INTERNAL_ERROR'`. It no longer carries no `code`, which supersedes the sentence of the pending `@objectstack/objectql` changeset that said it did. For the refusal itself, the REST data door's answer is byte-identical: `500 { error: "Internal server error", code: "INTERNAL_ERROR" }`, now declared instead of reached by falling through every message heuristic. **The action door now answers these refusals `500 INTERNAL_ERROR`, not `400`**, with the same sentence. **A converted refusal relayed out of a sandboxed hook body** (its `ctx.api` cascade delete or transaction was stopped, and the body let the error escape) **now answers `500 INTERNAL_ERROR` at the REST data door, where it answered `400` with the inner sentence.** The dispatcher data door and the MCP tools keep status `500` and code `INTERNAL_ERROR` for these refusals, but now withhold the prose, as they do for any declared 5xx.
19+
- **A failed rollback.** When `ObjectQL.transaction`'s rollback itself threw, the refusal still said "The transaction rolled back, so nothing written inside it was stored". It now says that the rollback failed and that what was written inside it may or may not be stored.
20+
21+
**The remedy.** Fix the summary field's `summaryOperations` (the engine's warning names why the recompute was refused), then retry. A hook that can tolerate a stale summary on its own write catches `ERR_SUMMARY_RECOMPUTE` itself.

‎packages/objectql/src/engine-cascade-summary-rollback.test.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren
144144
const { engine, driver } = await makeEngine(true);
145145
const err: any = await engine.delete('acct', { where: { id: 'a1' } }).then(() => null, (e: unknown) => e);
146146
expect(err).toBeInstanceOf(Error);
147-
expect(err.code).toBeUndefined();
147+
expect(err.code).toBe('INTERNAL_ERROR');
148+
expect(err.status).toBe(500);
148149
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
149150
expect(err.written).toBeUndefined();
150151
expect(driver.rolledBack).toHaveLength(1);
@@ -156,7 +157,8 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren
156157
const { engine, driver } = await makeEngine(false);
157158
const err: any = await engine.delete('acct', { where: { id: 'a1' } }).then(() => null, (e: unknown) => e);
158159
expect(err).toBeInstanceOf(Error);
159-
expect(err.code).toBeUndefined();
160+
expect(err.code).toBe('INTERNAL_ERROR');
161+
expect(err.status).toBe(500);
160162
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
161163
expect(ids(driver.rowsOf('acct'))).toEqual(['a1']);
162164
// The dependent the walk reached first is gone: nothing can roll it back.
@@ -167,7 +169,8 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren
167169
const { engine, driver } = await makeEngine(false);
168170
const err: any = await engine.delete('acct', { where: { name: 'A' }, multi: true }).then(() => null, (e: unknown) => e);
169171
expect(err).toBeInstanceOf(Error);
170-
expect(err.code).toBeUndefined();
172+
expect(err.code).toBe('INTERNAL_ERROR');
173+
expect(err.status).toBe(500);
171174
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
172175
expect(ids(driver.rowsOf('acct'))).toEqual(['a1']);
173176
});
@@ -178,7 +181,8 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren
178181
.transaction(async (trx: any) => engine.delete('line', { where: { id: 'l1' }, context: trx }))
179182
.then(() => null, (e: unknown) => e);
180183
expect(err).toBeInstanceOf(Error);
181-
expect(err.code).toBeUndefined();
184+
expect(err.code).toBe('INTERNAL_ERROR');
185+
expect(err.status).toBe(500);
182186
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
183187
expect(driver.rolledBack).toHaveLength(1);
184188
expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']);
@@ -191,7 +195,8 @@ describe('a cascade dependent\'s ERR_SUMMARY_RECOMPUTE never answers for a paren
191195
.transaction(async (trx: any) => trx.object('line').deleteById('l1'))
192196
.then(() => null, (e: unknown) => e);
193197
expect(err).toBeInstanceOf(Error);
194-
expect(err.code).toBeUndefined();
198+
expect(err.code).toBe('INTERNAL_ERROR');
199+
expect(err.status).toBe(500);
195200
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
196201
expect(driver.rolledBack).toHaveLength(1);
197202
expect(ids(driver.rowsOf('line'))).toEqual(['l1', 'l2']);
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A hook's own `ctx.api` write that raises the stale-roll-up refusal never
5+
* answers for the write the hook is bound to.
6+
*
7+
* `SummaryRecomputeError` (`ERR_SUMMARY_RECOMPUTE`) means "this write is
8+
* stored, only a parent's roll-up recompute failed". It carries the write's
9+
* result as `written`, and every answering surface turns it into that write's
10+
* own success. A hook that writes through `ctx.api` makes a write of its own.
11+
* When that write raised the refusal and the handler let it escape, the refusal
12+
* reached the OUTER write's caller unchanged. So the caller read the hook's
13+
* record as the result of its own write: measured through the REST data door
14+
* before this file, `POST /data/case` answered `201` with the id and record of
15+
* the row the hook wrote. Under an `after*` hook's unit (`inAfterAbortUnit`) the
16+
* unit then committed, because the refusal says "written".
17+
*
18+
* Pinned here, at the one seam a handler's error leaves through
19+
* (`triggerHooks`):
20+
* - an aborting `afterInsert` / `afterUpdate` / `afterDelete` hook: the outer
21+
* write refuses with an error that is NOT the stale-roll-up refusal (a
22+
* declared `500 INTERNAL_ERROR`, no `written`, the refusal kept as `cause`),
23+
* leading with the outer write, and its unit rolls both rows back;
24+
* - a code-registered hook (no unit opens): the same refusal, whose text says
25+
* what stays written, and both rows are stored;
26+
* - a `before*` hook: the same refusal, and the outer row was never written.
27+
*
28+
* Controls: a hook declared `onError: 'log'` does not fail the outer write; the
29+
* outer write's OWN refusal still answers `ERR_SUMMARY_RECOMPUTE` carrying its
30+
* own result (and its unit still commits); any other error a hook throws passes
31+
* through unchanged.
32+
*
33+
* The driver rolls back for real (a snapshot per transaction), and refuses
34+
* every write to `bucket` while `refuseBucket` is set: that is the roll-up
35+
* recompute's parent update, refused without a transient message, so no retry
36+
* runs.
37+
*/
38+
39+
import { describe, it, expect } from 'vitest';
40+
import type { Hook } from '@objectstack/spec/data';
41+
import { ObjectQL } from './engine.js';
42+
import { bindHooksToEngine } from './hook-binder.js';
43+
44+
type Row = Record<string, unknown>;
45+
46+
function makeDriver() {
47+
let stores = new Map<string, Map<string, Row>>();
48+
const storeFor = (o: string) => stores.get(o) ?? (stores.set(o, new Map()), stores.get(o)!);
49+
const matches = (row: Row, where: any) =>
50+
!where || Object.entries(where).every(([k, v]) => k.startsWith('$') || row[k] === v);
51+
const begun: unknown[] = [];
52+
const committed: unknown[] = [];
53+
const rolledBack: unknown[] = [];
54+
const snapshots = new Map<unknown, typeof stores>();
55+
const state = { refuseBucket: false };
56+
let n = 0;
57+
const driver: any = {
58+
name: 'primary', version: '0.0.0', supports: {}, begun, committed, rolledBack, state,
59+
rowsOf: (o: string) => Array.from(storeFor(o).values()),
60+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
61+
async syncSchema() {},
62+
async find(o: string, ast: any) {
63+
const rows = Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where));
64+
return typeof ast?.limit === 'number' ? rows.slice(0, ast.limit) : rows;
65+
},
66+
async findOne(o: string, ast: any) { return (await this.find(o, ast))[0] ?? null; },
67+
async create(o: string, data: Row) {
68+
const row = { ...data, id: (data.id as string) ?? `${o}_${++n}` };
69+
storeFor(o).set(String(row.id), row);
70+
return row;
71+
},
72+
async update(o: string, id: string, data: Row) {
73+
if (state.refuseBucket && o === 'bucket') throw new Error('bucket store refused the write');
74+
const row = { ...storeFor(o).get(String(id)), ...data, id };
75+
storeFor(o).set(String(id), row);
76+
return row;
77+
},
78+
async delete(o: string, id: string) { return storeFor(o).delete(String(id)); },
79+
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
80+
async bulkCreate(o: string, rows: Row[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
81+
async bulkUpdate() { return []; }, async bulkDelete() {},
82+
async beginTransaction() {
83+
const handle = { trx: begun.length + 1 };
84+
snapshots.set(handle, new Map(Array.from(stores, ([o, s]) => [o, new Map(Array.from(s, ([k, v]) => [k, { ...v }]))])));
85+
begun.push(handle);
86+
return handle;
87+
},
88+
async commit(h: unknown) { snapshots.delete(h); committed.push(h); },
89+
async rollback(h: unknown) { stores = snapshots.get(h) ?? stores; rolledBack.push(h); },
90+
};
91+
return driver;
92+
}
93+
94+
const text = (name: string) => ({ name, type: 'text' });
95+
/** The hook's own write: a `line`, whose roll-up into `bucket` the driver refuses. */
96+
const writeLine = (ctx: any) => ctx.api.object('line').insert({ name: `from-${ctx.event}`, bucket: 'b1' });
97+
const hook = (events: string[], handler: (ctx: any) => unknown, extra: Partial<Hook> = {}) =>
98+
({ name: 'line_writer', object: 'case', events, handler, ...extra }) as unknown as Hook;
99+
100+
async function makeEngine(hooks: Hook[]) {
101+
const log = () => {};
102+
const engine = new ObjectQL({ logger: { debug: log, info: log, warn: log, error: log } } as any);
103+
(engine as any).summaryRetryOptions = { sleep: async () => {}, backoffBaseMs: 0 };
104+
const driver = makeDriver();
105+
engine.registerDriver(driver, true);
106+
await engine.init();
107+
engine.registry.registerObject({ name: 'case', label: 'case', fields: { id: { ...text('id'), primaryKey: true }, name: text('name') } } as any, '__test__');
108+
engine.registry.registerObject({
109+
name: 'bucket', label: 'bucket',
110+
fields: {
111+
id: { ...text('id'), primaryKey: true }, name: text('name'),
112+
line_count: { name: 'line_count', type: 'summary', summaryOperations: { object: 'line', function: 'count' } },
113+
},
114+
} as any, '__test__');
115+
engine.registry.registerObject({
116+
name: 'line', label: 'line',
117+
fields: { id: { ...text('id'), primaryKey: true }, name: text('name'), bucket: { name: 'bucket', type: 'master_detail', reference: 'bucket' } },
118+
} as any, '__test__');
119+
await engine.insert('bucket', { id: 'b1', name: 'B' });
120+
await engine.insert('case', { id: 'c0', name: 'existing' });
121+
bindHooksToEngine(engine, hooks, { packageId: 'app:t' });
122+
driver.begun.length = 0;
123+
driver.committed.length = 0;
124+
driver.rolledBack.length = 0;
125+
driver.state.refuseBucket = true;
126+
return { engine, driver };
127+
}
128+
129+
const refusal = (p: Promise<unknown>): Promise<any> => p.then(() => null, (e: unknown) => e);
130+
const names = (rows: Row[]) => rows.map((r) => r.name).sort();
131+
132+
/** The converted refusal: an internal error naming the OUTER write, the hook write's refusal kept as `cause`. */
133+
function expectHookStopped(err: any, lead: string) {
134+
expect(err).toBeInstanceOf(Error);
135+
expect(err.code).toBe('INTERNAL_ERROR');
136+
expect(err.status).toBe(500);
137+
expect(err.written).toBeUndefined();
138+
expect(err.failures).toBeUndefined();
139+
expect(err.cause?.code).toBe('ERR_SUMMARY_RECOMPUTE');
140+
expect(err.cause?.written?.name).toMatch(/^from-/);
141+
expect(err.message.startsWith(lead), err.message).toBe(true);
142+
expect(err.message).toContain('bucket.line_count');
143+
}
144+
145+
describe('a hook\'s own ctx.api write that raises ERR_SUMMARY_RECOMPUTE never answers for the write it is bound to', () => {
146+
it('afterInsert (aborting, unit open): the insert refuses naming itself, and the unit rolls both rows back', async () => {
147+
const { engine, driver } = await makeEngine([hook(['afterInsert'], writeLine)]);
148+
const err = await refusal(engine.insert('case', { name: 'c-1' }));
149+
expectHookStopped(err, 'The insert of "case" did not complete: its afterInsert hook "line_writer" stopped.');
150+
expect(driver.begun).toHaveLength(1);
151+
expect(driver.committed).toHaveLength(0);
152+
expect(driver.rolledBack).toHaveLength(1);
153+
expect(names(driver.rowsOf('case'))).toEqual(['existing']);
154+
expect(driver.rowsOf('line')).toHaveLength(0);
155+
});
156+
157+
it('afterUpdate and afterDelete (aborting, unit open): refused naming the outer write, the stored row unchanged', async () => {
158+
const upd = await makeEngine([hook(['afterUpdate'], writeLine)]);
159+
const updErr = await refusal(upd.engine.update('case', { id: 'c0', name: 'renamed' }));
160+
expectHookStopped(updErr, 'The update of "case" did not complete: its afterUpdate hook "line_writer" stopped.');
161+
expect(upd.driver.rolledBack).toHaveLength(1);
162+
expect(names(upd.driver.rowsOf('case'))).toEqual(['existing']);
163+
expect(upd.driver.rowsOf('line')).toHaveLength(0);
164+
165+
const del = await makeEngine([hook(['afterDelete'], writeLine)]);
166+
const delErr = await refusal(del.engine.delete('case', { where: { id: 'c0' } }));
167+
expectHookStopped(delErr, 'The delete of "case" did not complete: its afterDelete hook "line_writer" stopped.');
168+
expect(del.driver.rolledBack).toHaveLength(1);
169+
expect(names(del.driver.rowsOf('case'))).toEqual(['existing']);
170+
expect(del.driver.rowsOf('line')).toHaveLength(0);
171+
});
172+
173+
it('a code-registered afterInsert (no unit opens): the same refusal, saying what stays written, and both rows stored', async () => {
174+
const { engine, driver } = await makeEngine([]);
175+
engine.registerHook('afterInsert', writeLine, { object: 'case', hookName: 'code_line_writer' });
176+
const err = await refusal(engine.insert('case', { name: 'c-1' }));
177+
expectHookStopped(err, 'The insert of "case" did not complete: its afterInsert hook "code_line_writer" stopped.');
178+
expect(err.message).toContain(
179+
'Under a transaction that rolls back nothing was written; otherwise what was written before that point stays written.',
180+
);
181+
expect(driver.begun).toHaveLength(0);
182+
expect(names(driver.rowsOf('case'))).toEqual(['c-1', 'existing']);
183+
expect(names(driver.rowsOf('line'))).toEqual(['from-afterInsert']);
184+
});
185+
186+
it('beforeInsert: the same refusal, and the outer row was never written', async () => {
187+
const { engine, driver } = await makeEngine([hook(['beforeInsert'], writeLine)]);
188+
const err = await refusal(engine.insert('case', { name: 'c-1' }));
189+
expectHookStopped(err, 'The insert of "case" did not complete: its beforeInsert hook "line_writer" stopped.');
190+
expect(names(driver.rowsOf('case'))).toEqual(['existing']);
191+
// No unit opens for a before* hook, so the hook's own write stays, as the text says.
192+
expect(names(driver.rowsOf('line'))).toEqual(['from-beforeInsert']);
193+
});
194+
195+
it('CONTROL onError: \'log\': the hook\'s refusal is logged, and the outer insert answers its own row', async () => {
196+
const { engine, driver } = await makeEngine([hook(['afterInsert'], writeLine, { onError: 'log' })]);
197+
const row: any = await engine.insert('case', { name: 'c-1' });
198+
expect(row?.name).toBe('c-1');
199+
expect(names(driver.rowsOf('case'))).toEqual(['c-1', 'existing']);
200+
expect(names(driver.rowsOf('line'))).toEqual(['from-afterInsert']);
201+
});
202+
203+
it('CONTROL the outer write\'s OWN refusal still answers ERR_SUMMARY_RECOMPUTE with its own result, and its unit commits', async () => {
204+
const { engine, driver } = await makeEngine([
205+
{ name: 'observe', object: 'line', events: ['afterInsert'], handler: () => {} } as unknown as Hook,
206+
]);
207+
const err = await refusal(engine.insert('line', { name: 'direct', bucket: 'b1' }));
208+
expect(err?.code).toBe('ERR_SUMMARY_RECOMPUTE');
209+
expect(err.written?.name).toBe('direct');
210+
expect(driver.begun).toHaveLength(1);
211+
expect(driver.committed).toHaveLength(1);
212+
expect(driver.rolledBack).toHaveLength(0);
213+
expect(names(driver.rowsOf('line'))).toEqual(['direct']);
214+
});
215+
216+
it('CONTROL any other error a hook throws passes through unchanged', async () => {
217+
const boom = new Error('refused by the hook');
218+
const { engine } = await makeEngine([hook(['afterInsert'], () => { throw boom; })]);
219+
expect(await refusal(engine.insert('case', { name: 'c-1' }))).toBe(boom);
220+
});
221+
});

0 commit comments

Comments
 (0)