Skip to content

Commit e25808d

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 e25808d

1 file changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
// Prototype-delegate to the real driver: every method not overridden below
36+
// resolves through the chain to `real` (no Proxy, no dynamic property
37+
// dispatch — the engine only calls a fixed set of driver methods).
38+
const wrapper = Object.create(real);
39+
wrapper.update = async (object: string, id: string, data: any, opts: any) => {
40+
plan.updateBefore?.(object, bump(`update:${object}`)); // may throw (blip, never committed)
41+
return real.update(object, id, data, opts);
42+
};
43+
wrapper.create = async (object: string, data: any, opts: any) => {
44+
plan.createBefore?.(object, bump(`create:${object}`)); // may throw before commit
45+
return real.create(object, data, opts);
46+
};
47+
wrapper.bulkCreate = async (object: string, rows: any[], opts: any) => {
48+
const res = await real.bulkCreate(object, rows, opts); // commit lands
49+
plan.bulkCreateAfter?.(object, rows, bump(`bulkCreate:${object}`)); // then response lost
50+
return res;
51+
};
52+
return wrapper;
53+
}
54+
55+
const INV = {
56+
name: 'inv',
57+
fields: {
58+
name: { type: 'text' },
59+
line_total: { type: 'summary', summaryOperations: { object: 'inv_line', field: 'amount', function: 'sum' } },
60+
},
61+
};
62+
const INV_LINE = {
63+
name: 'inv_line',
64+
fields: { amount: { type: 'number' }, inv: { type: 'master_detail', reference: 'inv' } },
65+
};
66+
const TASK = {
67+
name: 'task',
68+
fields: { name: { type: 'text', required: true }, code: { type: 'autonumber', format: 'T-{000}' } },
69+
};
70+
const WIDGET = {
71+
name: 'widget',
72+
fields: { name: { type: 'text' }, sku: { type: 'text' } },
73+
};
74+
// A self-referencing object. Deliberately NOT named `employee`/`user`/etc. —
75+
// CodeQL's PII heuristic treats such table names as sensitive data and then
76+
// flags the driver's (benign, non-security) SHA-1 index-name suffix as "weak
77+
// crypto on sensitive data". The self-ref shape is all this probe needs.
78+
const TREENODE = {
79+
name: 'treenode',
80+
fields: { name: { type: 'text' }, parent: { type: 'lookup', reference: 'treenode' } },
81+
};
82+
83+
const SEED_CONFIG = { dryRun: false, haltOnError: false, multiPass: true, defaultMode: 'insert', batchSize: 1000, transaction: false } as any;
84+
const logger = { info() {}, warn() {}, error() {}, debug() {} };
85+
86+
function metadataFor(objects: any[]) {
87+
const byName = new Map(objects.map((o) => [o.name, o]));
88+
return {
89+
getObject: async (name: string) => byName.get(name),
90+
listObjects: async () => objects,
91+
register: async () => {}, get: async (_t: string, n: string) => byName.get(n),
92+
list: async () => [], unregister: async () => {}, exists: async () => false, listNames: async () => [],
93+
} as any;
94+
}
95+
96+
describe('bulk-write hardening on a REAL SqlDriver (framework#3147–#3152, #3172, #3173)', () => {
97+
let dir: string | null = null;
98+
let engine: ObjectQL | null = null;
99+
100+
afterEach(async () => {
101+
try { await engine?.destroy(); } catch { /* noop */ }
102+
engine = null;
103+
if (dir) { rmSync(dir, { recursive: true, force: true }); dir = null; }
104+
});
105+
106+
async function boot(objects: any[], plan: FaultPlan = {}) {
107+
dir = mkdtempSync(join(tmpdir(), 'os-bulk-real-'));
108+
const real = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'data.sqlite') }, useNullAsDefault: true });
109+
await real.initObjects(objects); // create real tables + sequences
110+
const driver = wrapDriver(real, plan);
111+
engine = new ObjectQL();
112+
engine.registerDriver(driver, true);
113+
await engine.init();
114+
// Fast, deterministic summary-retry backoff (framework#3147).
115+
(engine as any).summaryRetryOptions = { sleep: async () => {}, backoffBaseMs: 0 };
116+
for (const o of objects) engine.registry.registerObject(o as any);
117+
return engine;
118+
}
119+
120+
it('#3172: insertMany culls a bad row per-row and writes survivors with contiguous autonumber (real SQL)', async () => {
121+
const e = await boot([TASK]);
122+
const outcomes = await e.insertMany('task', [{ name: 'a' }, { nope: 1 }, { name: 'b' }]);
123+
124+
expect(outcomes.map((o) => o.ok)).toEqual([true, false, true]);
125+
const rows = await e.find('task', {});
126+
expect(rows).toHaveLength(2);
127+
// Real persistent sequence — the dead row consumed no number.
128+
expect(rows.map((r: any) => r.code).sort()).toEqual(['T-001', 'T-002']);
129+
});
130+
131+
it('#3147: a transient blip on the parent summary update is retried and the roll-up lands (real SQL aggregate)', async () => {
132+
// First update to `inv` (the summary write) throws like turso, then succeeds.
133+
const e = await boot([INV, INV_LINE], {
134+
updateBefore: (object, callN) => { if (object === 'inv' && callN === 1) throw new Error('fetch failed'); },
135+
});
136+
const inv = await e.insert('inv', { name: 'INV-1' });
137+
await e.insert('inv_line', [{ inv: inv.id, amount: 10 }, { inv: inv.id, amount: 32 }]);
138+
139+
const parent: any = (await e.find('inv', { where: { id: inv.id } }))[0];
140+
expect(parent.line_total).toBe(42); // recomputed correctly after the retry
141+
});
142+
143+
it('#3149/#3173: a commit-then-lost-response retry does NOT duplicate seed rows (real SQL)', async () => {
144+
// The bulkCreate writes the rows to the real table, THEN throws — the exact
145+
// turso shape the in-memory mock could never produce.
146+
const e = await boot([WIDGET], {
147+
bulkCreateAfter: (object, _rows, callN) => { if (object === 'widget' && callN === 1) throw new Error('fetch failed'); },
148+
});
149+
const seeder = new SeedLoaderService(e as any, metadataFor([WIDGET]), logger as any);
150+
const result = await seeder.load({
151+
seeds: [{ object: 'widget', externalId: 'sku', mode: 'insert', env: ['prod', 'dev', 'test'],
152+
records: [{ name: 'A', sku: 'W-A' }, { name: 'B', sku: 'W-B' }] }] as any,
153+
config: SEED_CONFIG,
154+
});
155+
156+
expect(result.summary.totalErrored).toBe(0);
157+
const rows = await e.find('widget', {});
158+
expect(rows).toHaveLength(2); // recheck-by-externalId prevented the duplicate re-insert
159+
expect(rows.map((r: any) => r.sku).sort()).toEqual(['W-A', 'W-B']);
160+
});
161+
162+
it('#3150: a transient blip on the self-referencing seed path is retried, not dropped (real SQL)', async () => {
163+
// `treenode.parent -> treenode` forces the sequential writeRecord path;
164+
// the first create throws before commit, then the retry lands the row.
165+
const e = await boot([TREENODE], {
166+
createBefore: (object, callN) => { if (object === 'treenode' && callN === 1) throw new Error('fetch failed'); },
167+
});
168+
const seeder = new SeedLoaderService(e as any, metadataFor([TREENODE]), logger as any);
169+
const result = await seeder.load({
170+
seeds: [{ object: 'treenode', externalId: 'name', mode: 'insert', env: ['prod', 'dev', 'test'],
171+
records: [{ name: 'Alice' }, { name: 'Bob' }] }] as any,
172+
config: SEED_CONFIG,
173+
});
174+
175+
expect(result.summary.totalErrored).toBe(0);
176+
const rows = await e.find('treenode', {});
177+
expect(rows.map((r: any) => r.name).sort()).toEqual(['Alice', 'Bob']);
178+
});
179+
});

0 commit comments

Comments
 (0)