Skip to content

Commit 0147d3c

Browse files
committed
test(runtime): real-SqlDriver regression for the bulk-write hardening (#3147#3152, #3172, #3173)
The 2026-07-06 incident's root lesson: the in-memory mock driver masked the bug (stored real booleans, never threw a transient error), so faithful local repro stayed green while turso dropped rows. Every probe so far uses a mock — this wires the REAL ObjectQL engine to the REAL SqlDriver (better-sqlite3, on-disk) and injects turso's actual failure shapes through a thin driver proxy: - #3172 insertMany culls a bad row per-row and writes survivors with a contiguous persistent autonumber sequence. - #3147 a transient blip on the parent summary UPDATE is retried; the roll-up recomputes correctly over a real SQL aggregate. - #3149/#3173 a bulkCreate that commits then loses its response (the exact turso shape the mock can't produce) does NOT duplicate seed rows — the natural-key recheck runs against the real table. - #3150 a transient blip on the self-referencing sequential seed path is retried, not dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APnSDJneEDRh3Z51KGCLga
1 parent 13a0b6e commit 0147d3c

1 file changed

Lines changed: 183 additions & 0 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Real-driver regression for the bulk-write hardening (framework#3147–#3152,
4+
// #3172, #3173). The 2026-07-06 HotCRM incident's root lesson was that the
5+
// in-memory mock driver MASKED the bug: it stored real booleans and never
6+
// threw a transient error, so faithful local repro stayed green while turso
7+
// dropped rows in production. These tests wire the REAL ObjectQL engine to the
8+
// REAL SqlDriver (better-sqlite3, on-disk), and inject turso's actual failure
9+
// shapes (a `fetch failed` before commit; a commit that lands then loses its
10+
// response) through a thin driver proxy — the mocks cannot prove any of this.
11+
12+
import { describe, it, expect, afterEach } from 'vitest';
13+
import { mkdtempSync, rmSync } from 'node:fs';
14+
import { tmpdir } from 'node:os';
15+
import { join } from 'node:path';
16+
import { ObjectQL } from '@objectstack/objectql';
17+
import { SeedLoaderService } from '@objectstack/objectql';
18+
import { SqlDriver } from '@objectstack/driver-sql';
19+
20+
/**
21+
* Wrap a real driver so specific methods can fail the way turso does. Hooks
22+
* receive the 1-based call count for that (method, object) pair and may throw.
23+
* `*Before` hooks throw BEFORE the real op (a blip that never committed);
24+
* `*After` hooks run the real op first, then throw (commit landed, response
25+
* lost — the failure mode that makes naive retry duplicate rows).
26+
*/
27+
interface FaultPlan {
28+
updateBefore?: (object: string, callN: number) => void;
29+
createBefore?: (object: string, callN: number) => void;
30+
bulkCreateAfter?: (object: string, rows: any[], callN: number) => void;
31+
}
32+
function wrapDriver(real: any, plan: FaultPlan): any {
33+
const counts = new Map<string, number>();
34+
const bump = (k: string) => { const n = (counts.get(k) ?? 0) + 1; counts.set(k, n); return n; };
35+
return new Proxy(real, {
36+
get(target, prop, receiver) {
37+
const value = Reflect.get(target, prop, receiver);
38+
if (typeof value !== 'function') return value;
39+
if (prop === 'update' && plan.updateBefore) {
40+
return async (object: string, id: string, data: any, opts: any) => {
41+
plan.updateBefore!(object, bump(`update:${object}`));
42+
return value.call(target, object, id, data, opts);
43+
};
44+
}
45+
if (prop === 'create' && plan.createBefore) {
46+
return async (object: string, data: any, opts: any) => {
47+
plan.createBefore!(object, bump(`create:${object}`));
48+
return value.call(target, object, data, opts);
49+
};
50+
}
51+
if (prop === 'bulkCreate' && plan.bulkCreateAfter) {
52+
return async (object: string, rows: any[], opts: any) => {
53+
const res = await value.call(target, object, rows, opts); // commit lands
54+
plan.bulkCreateAfter!(object, rows, bump(`bulkCreate:${object}`)); // response lost
55+
return res;
56+
};
57+
}
58+
return value.bind(target);
59+
},
60+
});
61+
}
62+
63+
const INV = {
64+
name: 'inv',
65+
fields: {
66+
name: { type: 'text' },
67+
line_total: { type: 'summary', summaryOperations: { object: 'inv_line', field: 'amount', function: 'sum' } },
68+
},
69+
};
70+
const INV_LINE = {
71+
name: 'inv_line',
72+
fields: { amount: { type: 'number' }, inv: { type: 'master_detail', reference: 'inv' } },
73+
};
74+
const TASK = {
75+
name: 'task',
76+
fields: { name: { type: 'text', required: true }, code: { type: 'autonumber', format: 'T-{000}' } },
77+
};
78+
const WIDGET = {
79+
name: 'widget',
80+
fields: { name: { type: 'text' }, sku: { type: 'text' } },
81+
};
82+
const EMPLOYEE = {
83+
name: 'employee',
84+
fields: { name: { type: 'text' }, manager: { type: 'lookup', reference: 'employee' } },
85+
};
86+
87+
const SEED_CONFIG = { dryRun: false, haltOnError: false, multiPass: true, defaultMode: 'insert', batchSize: 1000, transaction: false } as any;
88+
const logger = { info() {}, warn() {}, error() {}, debug() {} };
89+
90+
function metadataFor(objects: any[]) {
91+
const byName = new Map(objects.map((o) => [o.name, o]));
92+
return {
93+
getObject: async (name: string) => byName.get(name),
94+
listObjects: async () => objects,
95+
register: async () => {}, get: async (_t: string, n: string) => byName.get(n),
96+
list: async () => [], unregister: async () => {}, exists: async () => false, listNames: async () => [],
97+
} as any;
98+
}
99+
100+
describe('bulk-write hardening on a REAL SqlDriver (framework#3147–#3152, #3172, #3173)', () => {
101+
let dir: string | null = null;
102+
let engine: ObjectQL | null = null;
103+
104+
afterEach(async () => {
105+
try { await engine?.destroy(); } catch { /* noop */ }
106+
engine = null;
107+
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
108+
});
109+
110+
async function boot(objects: any[], plan: FaultPlan = {}) {
111+
dir = mkdtempSync(join(tmpdir(), 'os-bulk-real-'));
112+
const real = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true });
113+
await real.initObjects(objects); // create real tables + sequences
114+
const driver = wrapDriver(real, plan);
115+
engine = new ObjectQL();
116+
engine.registerDriver(driver, true);
117+
await engine.init();
118+
// Fast, deterministic summary-retry backoff (framework#3147).
119+
(engine as any).summaryRetryOptions = { sleep: async () => {}, backoffBaseMs: 0 };
120+
for (const o of objects) engine.registry.registerObject(o as any);
121+
return engine;
122+
}
123+
124+
it('#3172: insertMany culls a bad row per-row and writes survivors with contiguous autonumber (real SQL)', async () => {
125+
const e = await boot([TASK]);
126+
const outcomes = await e.insertMany('task', [{ name: 'a' }, { nope: 1 }, { name: 'b' }]);
127+
128+
expect(outcomes.map((o) => o.ok)).toEqual([true, false, true]);
129+
const rows = await e.find('task', {});
130+
expect(rows).toHaveLength(2);
131+
// Real persistent sequence — the dead row consumed no number.
132+
expect(rows.map((r: any) => r.code).sort()).toEqual(['T-001', 'T-002']);
133+
});
134+
135+
it('#3147: a transient blip on the parent summary update is retried and the roll-up lands (real SQL aggregate)', async () => {
136+
// First update to `inv` (the summary write) throws like turso, then succeeds.
137+
const e = await boot([INV, INV_LINE], {
138+
updateBefore: (object, callN) => { if (object === 'inv' && callN === 1) throw new Error('fetch failed'); },
139+
});
140+
const inv = await e.insert('inv', { name: 'INV-1' });
141+
await e.insert('inv_line', [{ inv: inv.id, amount: 10 }, { inv: inv.id, amount: 32 }]);
142+
143+
const parent: any = (await e.find('inv', { where: { id: inv.id } }))[0];
144+
expect(parent.line_total).toBe(42); // recomputed correctly after the retry
145+
});
146+
147+
it('#3149/#3173: a commit-then-lost-response retry does NOT duplicate seed rows (real SQL)', async () => {
148+
// The bulkCreate writes the rows to the real table, THEN throws — the exact
149+
// turso shape the in-memory mock could never produce.
150+
const e = await boot([WIDGET], {
151+
bulkCreateAfter: (object, _rows, callN) => { if (object === 'widget' && callN === 1) throw new Error('fetch failed'); },
152+
});
153+
const seeder = new SeedLoaderService(e as any, metadataFor([WIDGET]), logger as any);
154+
const result = await seeder.load({
155+
seeds: [{ object: 'widget', externalId: 'sku', mode: 'insert', env: ['prod', 'dev', 'test'],
156+
records: [{ name: 'A', sku: 'W-A' }, { name: 'B', sku: 'W-B' }] }] as any,
157+
config: SEED_CONFIG,
158+
});
159+
160+
expect(result.summary.totalErrored).toBe(0);
161+
const rows = await e.find('widget', {});
162+
expect(rows).toHaveLength(2); // recheck-by-externalId prevented the duplicate re-insert
163+
expect(rows.map((r: any) => r.sku).sort()).toEqual(['W-A', 'W-B']);
164+
});
165+
166+
it('#3150: a transient blip on the self-referencing seed path is retried, not dropped (real SQL)', async () => {
167+
// `employee.manager -> employee` forces the sequential writeRecord path;
168+
// the first create throws before commit, then the retry lands the row.
169+
const e = await boot([EMPLOYEE], {
170+
createBefore: (object, callN) => { if (object === 'employee' && callN === 1) throw new Error('fetch failed'); },
171+
});
172+
const seeder = new SeedLoaderService(e as any, metadataFor([EMPLOYEE]), logger as any);
173+
const result = await seeder.load({
174+
seeds: [{ object: 'employee', externalId: 'name', mode: 'insert', env: ['prod', 'dev', 'test'],
175+
records: [{ name: 'Alice' }, { name: 'Bob' }] }] as any,
176+
config: SEED_CONFIG,
177+
});
178+
179+
expect(result.summary.totalErrored).toBe(0);
180+
const rows = await e.find('employee', {});
181+
expect(rows.map((r: any) => r.name).sort()).toEqual(['Alice', 'Bob']);
182+
});
183+
});

0 commit comments

Comments
 (0)