-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathnested-write.integration.test.ts
More file actions
177 lines (165 loc) · 8.09 KB
/
Copy pathnested-write.integration.test.ts
File metadata and controls
177 lines (165 loc) · 8.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* End-to-end regression for #1867 — nested cross-object writes from a hook.
*
* A child object's `afterInsert` / `afterUpdate` hook issues an engine write to
* its parent (`ctx.api.object('parent').update(...)`). The parent *also* has a
* hook, so the child's nested write fires a SECOND sandbox VM while the child's
* hook is still in flight — the exact re-entrancy that used to crash the process
* with `memory access out of bounds` under the old single-suspended-asyncify
* model. This wires a REAL {@link ObjectQL} engine (in-memory driver) to the
* real {@link QuickJSScriptRunner} through {@link hookBodyRunnerFactory}, so the
* whole hook → sandbox → nested-write → nested-hook path is exercised.
*
* This is the "when a child changes, update the parent" rollup the issue says
* authors could not write; it must complete without a crash and land the
* correct denormalized total on the parent.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql';
import { hookBodyRunnerFactory } from './body-runner.js';
import { QuickJSScriptRunner } from './quickjs-runner.js';
const parent = {
name: 'parent',
label: 'Parent',
fields: {
id: { name: 'id', type: 'text' as const, primaryKey: true },
total_amount: { name: 'total_amount', type: 'number' as const },
child_count: { name: 'child_count', type: 'number' as const },
},
};
const child = {
name: 'child',
label: 'Child',
fields: {
id: { name: 'id', type: 'text' as const, primaryKey: true },
amount: { name: 'amount', type: 'number' as const },
parent_id: { name: 'parent_id', type: 'text' as const },
},
};
function makeMemoryDriver() {
const stores = new Map<string, Map<string, Record<string, unknown>>>();
const storeFor = (o: string) => {
let s = stores.get(o);
if (!s) { s = new Map(); stores.set(o, s); }
return s;
};
let nextId = 0;
const matches = (row: Record<string, unknown>, where: any): boolean => {
if (!where || typeof where !== 'object') return true;
for (const [k, v] of Object.entries(where)) {
if (k.startsWith('$')) continue;
const exp = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v;
if ((row[k] ?? null) !== (exp ?? null)) return false;
}
return true;
};
const driver: any = {
name: 'memory', version: '0.0.0', supports: {},
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
async find(o: string, ast: any) { return Array.from(storeFor(o).values()).filter((r) => matches(r, ast?.where)); },
findStream() { throw new Error('ns'); },
async findOne(o: string, ast: any) { for (const r of storeFor(o).values()) if (matches(r, ast?.where)) return r; return null; },
async create(o: string, data: Record<string, unknown>) {
nextId += 1; const id = (data.id as string) ?? `r_${nextId}`; const row = { ...data, id }; storeFor(o).set(id, row); return row;
},
async update(o: string, id: string, data: Record<string, unknown>) {
const s = storeFor(o); const cur = s.get(id); if (!cur) throw new Error(`nf ${o}/${id}`);
const up = { ...cur, ...data, id }; s.set(id, up); return up;
},
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); },
async delete(o: string, id: string) { return storeFor(o).delete(id); },
async count(o: string, ast: any) { return (await this.find(o, ast)).length; },
async bulkCreate(o: string, rows: Record<string, unknown>[]) { return Promise.all(rows.map((r) => this.create(o, r))); },
async bulkUpdate() { return []; }, async bulkDelete() {},
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, async commit() {}, async rollback() {},
};
return { driver, stores };
}
describe('#1867 nested cross-object write from a hook (real engine + sandbox)', () => {
let engine: ObjectQL;
beforeEach(async () => {
engine = new ObjectQL();
const { driver } = makeMemoryDriver();
engine.registerDriver(driver, true);
await engine.init();
for (const o of [parent, child]) engine.registry.registerObject(o as any);
// Runs at the STOCK 250ms budget on purpose (ADR-0102 D1): the budget is now
// CPU-time, so the child hook parking on its nested `parent.update(...)` — and
// the parent hook that fires host-side inside it — is not charged to the
// child. If this suite ever needs a generous budget again, that is a
// regression in CPU accounting, not an inherent cost of nested writes.
engine.setDefaultBodyRunner(
hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'test' }),
);
});
it('a child afterInsert hook writes the parent rollup without crashing (parent has its own hook → real nested VM)', async () => {
bindHooksToEngine(
engine,
[
{
name: 'rollup_parent_total',
object: 'child',
events: ['afterInsert', 'afterUpdate'],
body: {
language: 'js',
source: `
// On update the patch may omit parent_id; fall back to the
// pre-write record so the rollup targets the right parent.
const pid = ctx.input.parent_id || (ctx.previous && ctx.previous.parent_id);
const rows = await ctx.api.object('child').find({ where: { parent_id: pid } });
const total = rows.reduce((s, r) => s + (r.amount || 0), 0);
await ctx.api.object('parent').update({ id: pid, total_amount: total, child_count: rows.length });
`,
capabilities: ['api.read', 'api.write'],
},
} as any,
{
name: 'parent_touch_marker',
object: 'parent',
events: ['afterUpdate'],
// Trivial body — its mere presence forces a second sandbox VM to run
// (re-entrant) while the child's hook VM is still suspended.
body: { language: 'js', source: "return { seen: ctx.input.id };", capabilities: [] },
} as any,
],
{ packageId: 'test' },
);
const p = await engine.insert('parent', { id: 'p1', total_amount: 0, child_count: 0 });
await engine.insert('child', { id: 'c1', amount: 100, parent_id: p.id });
await engine.insert('child', { id: 'c2', amount: 50, parent_id: p.id });
const parentRow = (await engine.findOne('parent', { where: { id: 'p1' } })) as any;
expect(parentRow.total_amount).toBe(150);
expect(parentRow.child_count).toBe(2);
}, 20000);
it('re-rolls up on child update (afterUpdate nested write)', async () => {
bindHooksToEngine(
engine,
[
{
name: 'rollup_parent_total',
object: 'child',
events: ['afterInsert', 'afterUpdate'],
body: {
language: 'js',
source: `
// On update the patch may omit parent_id; fall back to the
// pre-write record so the rollup targets the right parent.
const pid = ctx.input.parent_id || (ctx.previous && ctx.previous.parent_id);
const rows = await ctx.api.object('child').find({ where: { parent_id: pid } });
const total = rows.reduce((s, r) => s + (r.amount || 0), 0);
await ctx.api.object('parent').update({ id: pid, total_amount: total, child_count: rows.length });
`,
capabilities: ['api.read', 'api.write'],
},
} as any,
],
{ packageId: 'test' },
);
await engine.insert('parent', { id: 'p1', total_amount: 0, child_count: 0 });
await engine.insert('child', { id: 'c1', amount: 100, parent_id: 'p1' });
expect(((await engine.findOne('parent', { where: { id: 'p1' } })) as any).total_amount).toBe(100);
await engine.update('child', { id: 'c1', amount: 250, parent_id: 'p1' });
expect(((await engine.findOne('parent', { where: { id: 'p1' } })) as any).total_amount).toBe(250);
}, 20000);
});