Skip to content

Commit d1d1c40

Browse files
os-zhuangclaude
andauthored
fix(runtime): honor hook body timeoutMs so nested cross-object writes aren't clamped to 250ms (#1867) (#3232)
* fix(runtime): honor hook body timeoutMs so nested cross-object writes aren't clamped to 250ms (#1867) Nested cross-object writes from a hook (`ctx.api.object('parent').update(...)` from a child's afterInsert/afterUpdate) once crashed the QuickJS sandbox with `memory access out of bounds` — the old single-suspended-asyncify host-call model could not unwind the WASM stack twice. That crash is already fixed on main by the deferred-promise + pump host-call model (host calls no longer asyncify; each invocation gets its own isolated VM), so any depth of nested re-entrancy now composes safely. The remaining reliability blocker was the timeout: `resolveTimeout` folded the engine default (250ms for hooks) straight into `Math.min(...)`, so it always dominated. A hook body that declared a larger `timeoutMs` (the spec allows up to 30_000ms via `ScriptBody.timeoutMs`) to give a legitimate nested-write rollup room to settle was silently clamped back to 250ms and killed mid-flight — a declared-but-unenforced knob (AGENTS.md Prime Directive #10) that pushed authors toward denormalized hand-maintained rollups. The engine default is now a FALLBACK used only when no explicit timeout is supplied, not a hard ceiling. An explicit `body.timeoutMs` (and/or an enclosing hook/action timeout via opts) is honored; when both are present the smaller wins. Unspecified bodies still get the 250ms hook / 5000ms action default, and a body may still lower its own timeout below the default. Tests: - quickjs-runner.test.ts — nested sandbox re-entrancy (single, 4-level chain, concurrent fan-out) does not crash; timeout resolution honors a body's declared timeoutMs above the default, still applies the default when unspecified, and still lets a body lower it. - nested-write.integration.test.ts — real ObjectQL engine + sandbox: a child afterInsert/afterUpdate hook rolls its total up to the parent (parent also has a hook → real nested VM) without crashing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP * docs(automation): document nested cross-object writes from hook bodies (#1867) Add a "Nested cross-object writes" note to the hook-bodies guide: a body may write other objects (child afterInsert/afterUpdate → parent update), the target's own hooks fire in a fresh sandbox VM, it composes to any depth, and a deeper or slower rollup chain should declare a larger timeoutMs (up to 30s) rather than rely on the 250ms single-write default. Documents the capability the runtime now delivers instead of the denormalized workaround. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP * test(runtime): real-SQLite regression for nested cross-object write rollup (#1867) Wire the real ObjectQL engine to the real SqlDriver (better-sqlite3, on-disk) and drive the expense-template rollup pattern through the hook → sandbox → nested-write path: an expense_line afterInsert/afterUpdate hook recomputes and writes expense_report.total_amount. Confirms the child→parent nested write lands the correct total on a real database with no `memory access out of bounds` crash — the platform limit the templates' CHARTERs documented as forcing denormalized workarounds. Scoped to insert/update (where the hook can resolve the child FK); a comment notes delete-safe aggregate rollups belong to the native `summary` field, since an afterDelete hook receives only `{ id, options }` with no pre-image of the deleted row's FK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2795f6 commit d1d1c40

6 files changed

Lines changed: 480 additions & 2 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): honor a hook body's declared `timeoutMs` so nested cross-object writes aren't clamped to 250ms (#1867)
6+
7+
Hook bodies run in the QuickJS sandbox with a default 250ms timeout. The runner
8+
folded that engine default straight into `Math.min(...)` when resolving the
9+
effective timeout, so it *always* dominated for hooks: a body that declared a
10+
larger `timeoutMs` (the spec permits up to 30_000ms — `ScriptBody.timeoutMs`) to
11+
give a legitimate nested write — "when a child changes, update the parent" —
12+
room to settle was silently clamped back to 250ms and killed mid-flight. The
13+
declared knob was never enforced.
14+
15+
The engine default is now a FALLBACK used only when no explicit timeout is
16+
supplied, not a hard ceiling. An explicit `body.timeoutMs` (and/or an enclosing
17+
hook/action timeout) is honored; when both are present the smaller wins. Bodies
18+
that declare nothing still get the 250ms hook / 5000ms action default, and a
19+
body may still LOWER its own timeout below the default.
20+
21+
This clears the last reliability blocker for nested cross-object writes from
22+
hooks — the sandbox crash itself (`memory access out of bounds`) was already
23+
fixed by the deferred-promise host-call model — so header/rollup fields no
24+
longer need denormalized, hand-maintained workarounds.

content/docs/automation/hook-bodies.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ The sandbox engine is **`quickjs-emscripten`** — pure-WASM, runs on every JS h
131131

132132
Per-invocation timeouts default to **250ms** for hooks and **5000ms** for actions; per-invocation memory caps at **32 MB**. Both are overridable per body.
133133

134+
### Nested cross-object writes
135+
136+
A body may write *other* objects — e.g. `await ctx.api.object('parent').update({ ... })` from a child's `afterInsert`/`afterUpdate` (requires `api.write`). The target's own hooks fire too: the nested write runs in a **fresh sandbox VM** while the calling body is suspended, and this composes to any depth. This is the natural "when a child changes, roll the total up to the parent" automation — it does **not** need a denormalized, hand-maintained mirror field. The 250ms default suits a single quick write; give a deeper or slower rollup chain headroom by declaring a larger `timeoutMs` on the outer body (up to 30_000ms), so the whole chain settles within budget.
137+
134138
## L3 — Compiled modules (intentionally disabled)
135139

136140
An earlier design allowed the CLI to emit a sibling `objectstack-runtime.<hash>.mjs` that `objectos` would `import()` at runtime. We removed that path because:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Real-SQLite regression for #1867 — a nested cross-object write from a hook.
5+
*
6+
* This is the exact automation the `objectstack-ai/templates` CHARTERs say
7+
* authors could not write ("when a child changes, update the parent"), modeled
8+
* on the expense template: an `expense_line` hook recomputes and writes
9+
* `expense_report.total_amount`. The in-memory mock can hide driver/transaction
10+
* behavior (cf. bulk-write-real-driver.integration.test.ts), so this wires the
11+
* REAL {@link ObjectQL} engine to the REAL {@link SqlDriver} (better-sqlite3,
12+
* on-disk) and drives the whole hook → sandbox → nested-write path — insert,
13+
* update, and delete of a line — asserting the parent rollup lands each time
14+
* and the process never crashes with `memory access out of bounds`.
15+
*
16+
* SCOPE: this exercises the insert/update rollup — the cases a hook can resolve,
17+
* where the child's FK is in the payload. Delete-inclusive AGGREGATE rollups are
18+
* better served by the engine's native `summary` field: an `afterDelete` hook
19+
* receives only `{ id, options }` (no pre-image of the deleted row's FK), so it
20+
* cannot know which parent to recompute, whereas the engine captures that
21+
* pre-image itself and recomputes summaries on delete (proven under real SQL in
22+
* `bulk-write-real-driver.integration.test.ts`). The nested-write hook is the
23+
* GENERAL mechanism #1867 unblocks (conditional / non-aggregate cross-object
24+
* writes); the `summary` field is the declarative tool for delete-safe sums.
25+
*/
26+
27+
import { describe, it, expect, afterEach } from 'vitest';
28+
import { mkdtempSync, rmSync } from 'node:fs';
29+
import { tmpdir } from 'node:os';
30+
import { join } from 'node:path';
31+
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
32+
import { SqlDriver } from '@objectstack/driver-sql';
33+
import { hookBodyRunnerFactory } from './body-runner.js';
34+
import { QuickJSScriptRunner } from './quickjs-runner.js';
35+
36+
const EXPENSE_REPORT = {
37+
name: 'expense_report',
38+
fields: {
39+
title: { type: 'text' },
40+
total_amount: { type: 'number' },
41+
line_count: { type: 'number' },
42+
},
43+
};
44+
const EXPENSE_LINE = {
45+
name: 'expense_line',
46+
fields: {
47+
amount: { type: 'number' },
48+
// The owning report id. Kept a plain text column so this test isolates the
49+
// nested-write behavior from FK/cascade machinery.
50+
expense_report: { type: 'text' },
51+
},
52+
};
53+
54+
const ROLLUP_HOOK = {
55+
name: 'expense_line_rollup',
56+
object: 'expense_line',
57+
events: ['afterInsert', 'afterUpdate'],
58+
body: {
59+
language: 'js',
60+
source: `
61+
const rid = ctx.input.expense_report;
62+
if (!rid) return;
63+
const lines = await ctx.api.object('expense_line').find({ where: { expense_report: rid } });
64+
const total = lines.reduce((s, l) => s + (l.amount || 0), 0);
65+
await ctx.api.object('expense_report').update({ id: rid, total_amount: total, line_count: lines.length });
66+
`,
67+
capabilities: ['api.read', 'api.write'],
68+
},
69+
};
70+
71+
describe('#1867 nested cross-object write — REAL SqlDriver (better-sqlite3, on-disk)', () => {
72+
let engine: ObjectQL | null = null;
73+
let dir: string | null = null;
74+
75+
afterEach(async () => {
76+
try { await engine?.destroy(); } catch { /* noop */ }
77+
engine = null;
78+
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
79+
});
80+
81+
async function boot() {
82+
dir = mkdtempSync(join(tmpdir(), 'os-nested-1867-'));
83+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true });
84+
await driver.initObjects([EXPENSE_REPORT, EXPENSE_LINE]); // create real tables
85+
engine = new ObjectQL();
86+
engine.registerDriver(driver, true);
87+
await engine.init();
88+
for (const o of [EXPENSE_REPORT, EXPENSE_LINE]) engine.registry.registerObject(o as any);
89+
engine.setDefaultBodyRunner(hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'expense' }));
90+
bindHooksToEngine(engine, [ROLLUP_HOOK as any], { packageId: 'expense' });
91+
return engine;
92+
}
93+
94+
it('rolls the child line total up to the parent on insert / update — no crash, correct total', async () => {
95+
const e = await boot();
96+
const report = await e.insert('expense_report', { title: 'Q3 travel', total_amount: 0, line_count: 0 });
97+
98+
// Insert two lines — each afterInsert nested-writes the parent.
99+
await e.insert('expense_line', { amount: 100, expense_report: report.id });
100+
const line2 = await e.insert('expense_line', { amount: 50, expense_report: report.id });
101+
let parent: any = (await e.find('expense_report', { where: { id: report.id } }))[0];
102+
expect(parent.total_amount).toBe(150);
103+
expect(parent.line_count).toBe(2);
104+
105+
// Update a line — afterUpdate re-rolls up.
106+
await e.update('expense_line', { id: line2.id, amount: 75, expense_report: report.id });
107+
parent = (await e.find('expense_report', { where: { id: report.id } }))[0];
108+
expect(parent.total_amount).toBe(175);
109+
}, 30000);
110+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* End-to-end regression for #1867 — nested cross-object writes from a hook.
5+
*
6+
* A child object's `afterInsert` / `afterUpdate` hook issues an engine write to
7+
* its parent (`ctx.api.object('parent').update(...)`). The parent *also* has a
8+
* hook, so the child's nested write fires a SECOND sandbox VM while the child's
9+
* hook is still in flight — the exact re-entrancy that used to crash the process
10+
* with `memory access out of bounds` under the old single-suspended-asyncify
11+
* model. This wires a REAL {@link ObjectQL} engine (in-memory driver) to the
12+
* real {@link QuickJSScriptRunner} through {@link hookBodyRunnerFactory}, so the
13+
* whole hook → sandbox → nested-write → nested-hook path is exercised.
14+
*
15+
* This is the "when a child changes, update the parent" rollup the issue says
16+
* authors could not write; it must complete without a crash and land the
17+
* correct denormalized total on the parent.
18+
*/
19+
20+
import { describe, it, expect, beforeEach } from 'vitest';
21+
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
22+
import { hookBodyRunnerFactory } from './body-runner.js';
23+
import { QuickJSScriptRunner } from './quickjs-runner.js';
24+
25+
const parent = {
26+
name: 'parent',
27+
label: 'Parent',
28+
fields: {
29+
id: { name: 'id', type: 'text' as const, primaryKey: true },
30+
total_amount: { name: 'total_amount', type: 'number' as const },
31+
child_count: { name: 'child_count', type: 'number' as const },
32+
},
33+
};
34+
const child = {
35+
name: 'child',
36+
label: 'Child',
37+
fields: {
38+
id: { name: 'id', type: 'text' as const, primaryKey: true },
39+
amount: { name: 'amount', type: 'number' as const },
40+
parent_id: { name: 'parent_id', type: 'text' as const },
41+
},
42+
};
43+
44+
function makeMemoryDriver() {
45+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
46+
const storeFor = (o: string) => {
47+
let s = stores.get(o);
48+
if (!s) { s = new Map(); stores.set(o, s); }
49+
return s;
50+
};
51+
let nextId = 0;
52+
const matches = (row: Record<string, unknown>, where: any): boolean => {
53+
if (!where || typeof where !== 'object') return true;
54+
for (const [k, v] of Object.entries(where)) {
55+
if (k.startsWith('$')) continue;
56+
const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
57+
if ((row[k] ?? null) !== (exp ?? null)) return false;
58+
}
59+
return true;
60+
};
61+
const driver: any = {
62+
name: 'memory', version: '0.0.0', supports: {},
63+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
64+
async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); },
65+
findStream() { throw new Error('ns'); },
66+
async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; },
67+
async create(o: string, data: Record<string, unknown>) {
68+
nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row;
69+
},
70+
async update(o: string, id: string, data: Record<string, unknown>) {
71+
const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`);
72+
const up = { ...cur, ...data, id }; s.set(id, up); return up;
73+
},
74+
async upsert(o: string, data: Record<string, unknown>) { const id = data.id as string | undefined; return id && storeFor(o).has(id) ? this.update(o, id, data) : this.create(o, data); },
75+
async delete(o: string, id: string) { return storeFor(o).delete(id); },
76+
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
77+
async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
78+
async bulkUpdate() { return []; }, async bulkDelete() {},
79+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {},
80+
};
81+
return { driver, stores };
82+
}
83+
84+
describe('#1867 nested cross-object write from a hook (real engine + sandbox)', () => {
85+
let engine: ObjectQL;
86+
87+
beforeEach(async () => {
88+
engine = new ObjectQL();
89+
const { driver } = makeMemoryDriver();
90+
engine.registerDriver(driver, true);
91+
await engine.init();
92+
for (const o of [parent, child]) engine.registry.registerObject(o as any);
93+
engine.setDefaultBodyRunner(
94+
hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'test' }),
95+
);
96+
});
97+
98+
it('a child afterInsert hook writes the parent rollup without crashing (parent has its own hook → real nested VM)', async () => {
99+
bindHooksToEngine(
100+
engine,
101+
[
102+
{
103+
name: 'rollup_parent_total',
104+
object: 'child',
105+
events: ['afterInsert', 'afterUpdate'],
106+
body: {
107+
language: 'js',
108+
source: `
109+
// On update the patch may omit parent_id; fall back to the
110+
// pre-write record so the rollup targets the right parent.
111+
const pid = ctx.input.parent_id || (ctx.previous && ctx.previous.parent_id);
112+
const rows = await ctx.api.object('child').find({ where: { parent_id: pid } });
113+
const total = rows.reduce((s, r) => s + (r.amount || 0), 0);
114+
await ctx.api.object('parent').update({ id: pid, total_amount: total, child_count: rows.length });
115+
`,
116+
capabilities: ['api.read', 'api.write'],
117+
},
118+
} as any,
119+
{
120+
name: 'parent_touch_marker',
121+
object: 'parent',
122+
events: ['afterUpdate'],
123+
// Trivial body — its mere presence forces a second sandbox VM to run
124+
// (re-entrant) while the child's hook VM is still suspended.
125+
body: { language: 'js', source: "return { seen: ctx.input.id };", capabilities: [] },
126+
} as any,
127+
],
128+
{ packageId: 'test' },
129+
);
130+
131+
const p = await engine.insert('parent', { id: 'p1', total_amount: 0, child_count: 0 });
132+
await engine.insert('child', { id: 'c1', amount: 100, parent_id: p.id });
133+
await engine.insert('child', { id: 'c2', amount: 50, parent_id: p.id });
134+
135+
const parentRow = (await engine.findOne('parent', { where: { id: 'p1' } })) as any;
136+
expect(parentRow.total_amount).toBe(150);
137+
expect(parentRow.child_count).toBe(2);
138+
}, 20000);
139+
140+
it('re-rolls up on child update (afterUpdate nested write)', async () => {
141+
bindHooksToEngine(
142+
engine,
143+
[
144+
{
145+
name: 'rollup_parent_total',
146+
object: 'child',
147+
events: ['afterInsert', 'afterUpdate'],
148+
body: {
149+
language: 'js',
150+
source: `
151+
// On update the patch may omit parent_id; fall back to the
152+
// pre-write record so the rollup targets the right parent.
153+
const pid = ctx.input.parent_id || (ctx.previous && ctx.previous.parent_id);
154+
const rows = await ctx.api.object('child').find({ where: { parent_id: pid } });
155+
const total = rows.reduce((s, r) => s + (r.amount || 0), 0);
156+
await ctx.api.object('parent').update({ id: pid, total_amount: total, child_count: rows.length });
157+
`,
158+
capabilities: ['api.read', 'api.write'],
159+
},
160+
} as any,
161+
],
162+
{ packageId: 'test' },
163+
);
164+
165+
await engine.insert('parent', { id: 'p1', total_amount: 0, child_count: 0 });
166+
await engine.insert('child', { id: 'c1', amount: 100, parent_id: 'p1' });
167+
expect(((await engine.findOne('parent', { where: { id: 'p1' } })) as any).total_amount).toBe(100);
168+
169+
await engine.update('child', { id: 'c1', amount: 250, parent_id: 'p1' });
170+
expect(((await engine.findOne('parent', { where: { id: 'p1' } })) as any).total_amount).toBe(250);
171+
}, 20000);
172+
});

0 commit comments

Comments
 (0)