|
| 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