Skip to content

Commit cc1600a

Browse files
committed
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
1 parent 5e8c5a5 commit cc1600a

1 file changed

Lines changed: 110 additions & 0 deletions

File tree

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+
});

0 commit comments

Comments
 (0)