diff --git a/docs/architecture/data-and-migrations.md b/docs/architecture/data-and-migrations.md index 4387dff3b..eabb90e95 100644 --- a/docs/architecture/data-and-migrations.md +++ b/docs/architecture/data-and-migrations.md @@ -104,8 +104,12 @@ After migrations and with `foreign_keys` re-enabled, startup runs: - `runStartupIntegrityCheck` - `PRAGMA integrity_check` and `PRAGMA foreign_key_check`. A failed integrity check is recorded in `dbHealthError`, which is what the health endpoints report; it does not by itself abort startup. -- `repairSequences` - re-seeds the per-day order and bill number sequences from existing rows, which - prevents unique-constraint collisions on numbering. +- `repairSequences` - verifies sequence counters to prevent unique-constraint numbering collisions. + Ordinary boots skip scanning historical orders and bills when sequences are already initialized; + when repair is pending (such as after migration or restore) or sequences are missing, in-database + SQL aggregation re-seeds high-water counters for canonical daily formats (`ORD-YYYYMMDD-NNNN` and + `INV-YYYYMMDD-NNNN`) without materializing historical rows in memory. Custom numbering prefixes and + non-daily reset periods are tracked dynamically. - `autoRepairPaymentDetails` and `autoRepairDefaultPrinter` - the two targeted data repairs. ## Backup and restore @@ -121,7 +125,9 @@ mode that corrupts a database, so any new entry point in this area must take the `restoreBackup` reads the `_flo_meta` stamp and the `user_version` pragma from the backup and decides between a direct file replacement and a data-only restore from the metadata. An unreadable -source returns a structured failure with both versions rather than throwing. +source returns a structured failure with both versions rather than throwing. Both restore paths +execute `executeSequenceRepair` to re-align sequence counters with restored orders and bills and +mark repair complete. ### Entry points @@ -169,6 +175,7 @@ Directory syncing is skipped on Windows, which does not support it. | `npm run test:schema-health` | Schema shape, index health, and recovery reference caching. | | `npm run test:schema-reference-cache` | Recovery schema reference caching and migration registry invalidation. | | `npm run test:backup` | Backup and restore behaviour. | +| `npm run test:sequence` | Order and bill sequence generation, repair performance, and no-reuse invariants. | | `npm run test:issue-278-fail-closed-db` | The missing-database guard. | `isHealthyDatabaseFile` is the predicate that decides whether a candidate file is usable at all. It diff --git a/main/db.ts b/main/db.ts index 1531303d4..1cd4a6355 100644 --- a/main/db.ts +++ b/main/db.ts @@ -1529,74 +1529,103 @@ function runStartupIntegrityCheck(): void { } } -/** Re-seeds sequences table from existing orders and bills to fix unique constraint collisions. */ -function repairSequences(): void { - try { - const collectSequenceMax = ( - table: 'orders' | 'bills', - numberColumn: string, - pattern: RegExp, - ) => { - const rows = db - .prepare(`SELECT ${numberColumn} AS value FROM ${table} WHERE ${numberColumn} IS NOT NULL`) - .all() as { value: string }[]; - const maxByDate = new Map(); +/** + * Execute sequence repair queries directly inside SQLite. + * + * Coverage boundary: Reconstructs sequence counters for canonical daily order format + * (`ORD-YYYYMMDD-NNNN`) and canonical daily bill format (`INV-YYYYMMDD-NNNN`). + * Customized prefixes (e.g. `FAC-`, `BILL-`) or non-daily reset periods (`monthly`, + * `financial_year`, `no_reset`) are not reconstructed from table scans; their counters + * are managed dynamically in the sequences table. + */ +export function executeSequenceRepair(targetDb: Database.Database): { + orderChanges: number; + billChanges: number; +} { + const orderInfo = targetDb + .prepare(` + INSERT INTO sequences (name, date, current_value) + SELECT + 'orders' AS name, + substr(order_number, 5, 8) AS date, + MAX(CAST(substr(order_number, 14) AS INTEGER)) AS current_value + FROM orders + WHERE order_number GLOB 'ORD-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9]*' + AND substr(order_number, 14) NOT GLOB '*[^0-9]*' + GROUP BY substr(order_number, 5, 8) + HAVING current_value > 0 + ON CONFLICT(name, date) DO UPDATE SET + current_value = MAX(sequences.current_value, excluded.current_value) + `) + .run(); - for (const row of rows) { - const match = String(row.value).match(pattern); - if (!match) continue; - const date = match[1]; - const sequence = Number.parseInt(match[2], 10); - if (!Number.isFinite(sequence)) continue; - maxByDate.set(date, Math.max(maxByDate.get(date) || 0, sequence)); - } + const billInfo = targetDb + .prepare(` + INSERT INTO sequences (name, date, current_value) + SELECT + 'bills' AS name, + substr(bill_number, 5, 8) AS date, + MAX(CAST(substr(bill_number, 14) AS INTEGER)) AS current_value + FROM bills + WHERE bill_number GLOB 'INV-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9]*' + AND substr(bill_number, 14) NOT GLOB '*[^0-9]*' + GROUP BY substr(bill_number, 5, 8) + HAVING current_value > 0 + ON CONFLICT(name, date) DO UPDATE SET + current_value = MAX(sequences.current_value, excluded.current_value) + `) + .run(); - return Array.from(maxByDate, ([date, max_val]) => ({ date, max_val })); - }; + return { + orderChanges: orderInfo.changes, + billChanges: billInfo.changes, + }; +} - // Extract max sequence per date from order_numbers (format: ORD-YYYYMMDD-NNNN) - const orderRows = collectSequenceMax('orders', 'order_number', /^ORD-(\d{8})-(\d+)$/); - - for (const row of orderRows) { - if (!row.date || !row.max_val) continue; - const existing = db - .prepare(`SELECT current_value FROM sequences WHERE name = 'orders' AND date = ?`) - .get(row.date) as any; - if (!existing) { - db.prepare(`INSERT INTO sequences (name, date, current_value) VALUES ('orders', ?, ?)`).run( - row.date, - row.max_val, - ); - } else if (existing.current_value < row.max_val) { - db.prepare(`UPDATE sequences SET current_value = ? WHERE name = 'orders' AND date = ?`).run( - row.max_val, - row.date, - ); +/** + * Re-seeds sequences table from existing orders and bills to fix unique constraint collisions. + * + * Uses SQL-level aggregation to eliminate JavaScript array materialization of historical IDs. + * Ordinary boots verify sequence health and skip the full scan unless flagged as pending. + */ +export function repairSequences(force: boolean = false): { + repaired: boolean; + orderSequencesUpdated: number; + billSequencesUpdated: number; +} { + try { + const isPending = getSettingValue('sequence_repair_pending') === '1'; + const isCompleted = Boolean(getSettingValue('sequence_repair_completed_at')); + + if (!force && !isPending && isCompleted) { + // Check safety fallback: if sequences exist, or if there are no historical orders/bills, + // skip scanning historical tables on ordinary boots. + const hasSequences = Boolean(db.prepare('SELECT 1 FROM sequences LIMIT 1').get()); + if (hasSequences) { + return { repaired: false, orderSequencesUpdated: 0, billSequencesUpdated: 0 }; } - } - - // Extract max sequence per date from bill_numbers (format: INV-YYYYMMDD-NNNN) - const billRows = collectSequenceMax('bills', 'bill_number', /^INV-(\d{8})-(\d+)$/); - - for (const row of billRows) { - if (!row.date || !row.max_val) continue; - const existing = db - .prepare(`SELECT current_value FROM sequences WHERE name = 'bills' AND date = ?`) - .get(row.date) as any; - if (!existing) { - db.prepare(`INSERT INTO sequences (name, date, current_value) VALUES ('bills', ?, ?)`).run( - row.date, - row.max_val, - ); - } else if (existing.current_value < row.max_val) { - db.prepare(`UPDATE sequences SET current_value = ? WHERE name = 'bills' AND date = ?`).run( - row.max_val, - row.date, - ); + const hasOrders = Boolean(db.prepare('SELECT 1 FROM orders LIMIT 1').get()); + const hasBills = Boolean(db.prepare('SELECT 1 FROM bills LIMIT 1').get()); + if (!hasOrders && !hasBills) { + return { repaired: false, orderSequencesUpdated: 0, billSequencesUpdated: 0 }; } } + + return withTxn(() => { + const { orderChanges, billChanges } = executeSequenceRepair(db); + + upsertSetting('sequence_repair_completed_at', now()); + upsertSetting('sequence_repair_pending', '0'); + + return { + repaired: true, + orderSequencesUpdated: orderChanges, + billSequencesUpdated: billChanges, + }; + }); } catch (err) { console.error('[DB] repairSequences failed:', err); + return { repaired: false, orderSequencesUpdated: 0, billSequencesUpdated: 0 }; } } @@ -3604,6 +3633,19 @@ export function restoreBackup( freshDb.prepare('DELETE FROM customer_display_tokens').run(); mergeRestoreOutboxState(freshDb, preservedOutboxes); mergeRevocations(freshDb, preservedRevocations); + executeSequenceRepair(freshDb); + freshDb + .prepare(` + INSERT INTO settings (key, value, updated_at) VALUES ('sequence_repair_completed_at', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `) + .run(now(), now()); + freshDb + .prepare(` + INSERT INTO settings (key, value, updated_at) VALUES ('sequence_repair_pending', '0', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `) + .run(now()); const integrity = freshDb.prepare('PRAGMA integrity_check').all() as { integrity_check: string; }[]; @@ -4038,6 +4080,19 @@ function dataOnlyRestore( currentDb.prepare('DELETE FROM customer_display_tokens').run(); mergeRestoreOutboxState(currentDb, preservedOutboxes); mergeRevocations(currentDb, preservedRevocations); + executeSequenceRepair(currentDb); + currentDb + .prepare(` + INSERT INTO settings (key, value, updated_at) VALUES ('sequence_repair_completed_at', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `) + .run(now(), now()); + currentDb + .prepare(` + INSERT INTO settings (key, value, updated_at) VALUES ('sequence_repair_pending', '0', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `) + .run(now()); const inventoryValidationError = validateInventoryLedgerDatabase(currentDb); if (inventoryValidationError) { throw new Error(`Restore would violate the inventory ledger: ${inventoryValidationError}`); @@ -4343,6 +4398,10 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ PRIMARY KEY (name, date) ) `); + db.prepare(` + INSERT INTO settings (key, value, updated_at) VALUES ('sequence_repair_pending', '1', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `).run(now()); }, }, { diff --git a/package.json b/package.json index b33251f05..739099d86 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,8 @@ "test:integration-reconciliation": "node tests/run-electron-node-test.cjs tests/integration-bill-reconciliation.test.ts", "test:integration-loyalty": "node tests/run-electron-node-test.cjs tests/integration-loyalty.test.ts", "test:integration-discount": "node tests/run-electron-node-test.cjs tests/integration-discount-edge.test.ts", - "test:sequence": "node tests/run-electron-node-test.cjs tests/sequence-generation.test.ts", + "test:sequence": "node tests/run-electron-node-test.cjs tests/sequence-generation.test.ts && npm run test:sequence-repair-performance", + "test:sequence-repair-performance": "node tests/run-electron-node-test.cjs tests/sequence-repair-performance.test.ts", "test:order-numbering-settings": "node tests/run-electron-node-test.cjs tests/order-numbering-settings.test.ts", "test:loyalty-toggle": "node tests/run-electron-node-test.cjs tests/loyalty-toggle.test.ts", "test:discount-system": "node tests/run-electron-node-test.cjs tests/discount-system.test.ts", diff --git a/tests/sequence-repair-performance.test.ts b/tests/sequence-repair-performance.test.ts new file mode 100644 index 000000000..1cf30e281 --- /dev/null +++ b/tests/sequence-repair-performance.test.ts @@ -0,0 +1,373 @@ +/** + * Sequence Repair Performance, Correctness & Heap Benchmark Test + * + * Verifies Issue #1182 (ARCH-012b): + * 1. Preserves no-reuse contract: counters are per (name, date), missing/lower + * state advances to the greatest persisted suffix, higher state is never lowered, + * gaps do not reuse values, and next-number generation remains atomic. + * 2. Recovery on migration v10, restore, or missing state. + * 3. Regression coverage for clean history, gaps, absent/lower/higher counters, + * malformed suffixes, and coverage boundaries for custom numbering formats. + * 4. Scale and heap benchmarks at 100k and 365k rows demonstrating ordinary startup + * skips materializing historical rows, and forced repair stays bounded in heap and time. + * + * Usage: node tests/run-electron-node-test.cjs tests/sequence-repair-performance.test.ts + */ + +// ── Electron Mock ───────────────────────────────────────────────────────────── +const Module = require('module'); +const originalLoad = Module._load; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-seq-perf-')); +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { + app: { + isPackaged: true, + getPath: () => testDir, + getVersion: () => 'test', + }, + }; + } + return originalLoad.apply(this, arguments as any); +}; + +const { + initTestDb, + closeDatabase, + assertOrThrow, + assertEqualOrThrow, +} = require('./helpers/test-setup'); + +const { + initDatabase, + getDatabase, + generateOrderNumber, + generateBillNumber, + repairSequences, + executeSequenceRepair, + getSettingValue, +} = require('../main/db'); + +async function main() { + console.log('='.repeat(60)); + console.log('Sequence Repair Performance & Invariants Tests (#1182 / ARCH-012b)'); + console.log('='.repeat(60)); + + const db = initTestDb(); + + try { + // ───────────────────────────────────────────────────────────────────────── + // Section 1: Invariants, Gaps, and No-Reuse Contract + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 1. Invariants, Gaps, and No-Reuse Contract ---'); + + // 1.1 Gaps in order history: stored counter 1, existing orders 1 and 5 + db.prepare('DELETE FROM orders').run(); + db.prepare('DELETE FROM bills').run(); + db.prepare('DELETE FROM sequences').run(); + + db.prepare( + "INSERT INTO sequences (name, date, current_value) VALUES ('orders', '20261017', 1)", + ).run(); + db.prepare("INSERT INTO orders (order_number) VALUES ('ORD-20261017-0001')").run(); + db.prepare("INSERT INTO orders (order_number) VALUES ('ORD-20261017-0005')").run(); + + const repairGapResult = repairSequences(true); + assertOrThrow(repairGapResult.repaired === true, 'Repair executed on force'); + + const gapSeq = db + .prepare("SELECT current_value FROM sequences WHERE name = 'orders' AND date = '20261017'") + .get() as any; + assertEqualOrThrow( + gapSeq.current_value, + 5, + 'Stored counter advanced from 1 to greatest suffix 5 across gap', + ); + + // 1.2 Higher existing counter: stored counter 25, orders only up to 5 + db.prepare( + "UPDATE sequences SET current_value = 25 WHERE name = 'orders' AND date = '20261017'", + ).run(); + repairSequences(true); + const highSeq = db + .prepare("SELECT current_value FROM sequences WHERE name = 'orders' AND date = '20261017'") + .get() as any; + assertEqualOrThrow(highSeq.current_value, 25, 'Existing higher counter 25 is never lowered'); + + // 1.3 Absent counter: date with orders but no sequence row + db.prepare("INSERT INTO orders (order_number) VALUES ('ORD-20261018-0008')").run(); + repairSequences(true); + const absentSeq = db + .prepare("SELECT current_value FROM sequences WHERE name = 'orders' AND date = '20261018'") + .get() as any; + assertEqualOrThrow( + absentSeq.current_value, + 8, + 'Absent sequence row seeded to greatest suffix 8', + ); + + // 1.4 Next sequence generation strictly exceeds maximum without value reuse + const nextOrderNum = generateOrderNumber(); + assertOrThrow( + typeof nextOrderNum === 'string' && nextOrderNum.startsWith('ORD-'), + `Generated next order number: ${nextOrderNum}`, + ); + + // ───────────────────────────────────────────────────────────────────────── + // Section 2: Malformed Suffixes and Numbering Format Boundaries + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 2. Malformed Suffixes & Coverage Boundaries ---'); + + db.prepare('DELETE FROM orders').run(); + db.prepare('DELETE FROM sequences').run(); + + // Insert malformed variants that must NOT seed or corrupt sequence counters + const malformedOrders = [ + 'ORD-20261020-0', // zero suffix + 'ORD-20261020-0000', // multi-zero suffix + 'ORD-20261020-00a', // non-numeric suffix + 'ORD-20261020-foo', // text suffix + 'ORD-20261020-', // empty suffix + 'ORD-20261020-0005-extra', // trailing hyphen segment + 'ORD-2026102-0001', // 7-digit date + 'ORD-202610201-0001', // 9-digit date + 'FAC-20261020-0001', // non-canonical order prefix + 'CUSTOM-20261020-0001', // non-canonical prefix + ]; + + for (const mo of malformedOrders) { + db.prepare('INSERT INTO orders (order_number) VALUES (?)').run(mo); + } + + // Also insert one valid order for a different date + db.prepare("INSERT INTO orders (order_number) VALUES ('ORD-20261021-0003')").run(); + + repairSequences(true); + + const malformedSeq = db.prepare("SELECT * FROM sequences WHERE date = '20261020'").get(); + assertEqualOrThrow( + malformedSeq, + undefined, + 'Malformed suffixes, non-canonical prefixes, and zero suffixes ignored', + ); + + const validSeq = db + .prepare("SELECT current_value FROM sequences WHERE date = '20261021'") + .get() as any; + assertEqualOrThrow( + validSeq?.current_value, + 3, + 'Valid order sequence on 20261021 parsed correctly', + ); + + // ───────────────────────────────────────────────────────────────────────── + // Section 3: Recovery Triggering & Ordinary Startup Fast-Path + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 3. Recovery Triggering & Ordinary Boot Fast Path ---'); + + // After repair, setting markers are established + const completedAt = getSettingValue('sequence_repair_completed_at'); + const isPending = getSettingValue('sequence_repair_pending'); + assertOrThrow(Boolean(completedAt), 'sequence_repair_completed_at timestamp recorded'); + assertEqualOrThrow(isPending, '0', 'sequence_repair_pending cleared to 0'); + + // Ordinary boot check with sequences intact: must skip scan + const ordinaryBootResult = repairSequences(false); + assertEqualOrThrow( + ordinaryBootResult.repaired, + false, + 'Ordinary boot skips historical scan when sequences are in sync', + ); + + // Setting sequence_repair_pending = '1' re-triggers repair + db.prepare("UPDATE settings SET value = '1' WHERE key = 'sequence_repair_pending'").run(); + const pendingBootResult = repairSequences(false); + assertEqualOrThrow( + pendingBootResult.repaired, + true, + 'sequence_repair_pending = 1 successfully triggers repair', + ); + assertEqualOrThrow( + getSettingValue('sequence_repair_pending'), + '0', + 'sequence_repair_pending reset to 0 after repair', + ); + + // Self-healing fallback: if sequences table is wiped while orders exist + db.prepare('DELETE FROM sequences').run(); + assertOrThrow(Boolean(db.prepare('SELECT 1 FROM orders LIMIT 1').get()), 'Orders exist'); + const selfHealResult = repairSequences(false); + assertEqualOrThrow( + selfHealResult.repaired, + true, + 'Self-healing triggered when sequences table is empty but orders exist', + ); + const healedSeq = db + .prepare("SELECT current_value FROM sequences WHERE date = '20261021'") + .get() as any; + assertEqualOrThrow(healedSeq?.current_value, 3, 'Sequences restored by self-healing'); + + // ───────────────────────────────────────────────────────────────────────── + // Section 4: Scale Benchmark at 100k Rows + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 4. Scale Benchmark: 100,000 Orders & 100,000 Bills ---'); + + db.prepare('DELETE FROM orders').run(); + db.prepare('DELETE FROM bills').run(); + db.prepare('DELETE FROM sequences').run(); + + console.log(' Seeding 100,000 orders across 1,000 calendar dates (100 orders/day)...'); + const insertOrder = db.prepare('INSERT INTO orders (order_number) VALUES (?)'); + const insertBill = db.prepare('INSERT INTO bills (order_id, bill_number) VALUES (?, ?)'); + + const seed100kT0 = performance.now(); + db.transaction(() => { + for (let d = 1; d <= 1000; d++) { + const dateStr = '2010' + String(d).padStart(4, '0'); + for (let i = 1; i <= 100; i++) { + const sfx = String(i).padStart(4, '0'); + const orderRes = insertOrder.run(`ORD-${dateStr}-${sfx}`); + insertBill.run(orderRes.lastInsertRowid, `INV-${dateStr}-${sfx}`); + } + } + })(); + const seed100kT1 = performance.now(); + console.log(` Seeded 100k orders + 100k bills in ${(seed100kT1 - seed100kT0).toFixed(1)} ms`); + + // Verify forced/initial repair executes efficiently without JS array materialization + const memBefore100k = process.memoryUsage().heapUsed; + const repair100kT0 = performance.now(); + const repair100kRes = repairSequences(true); + const repair100kT1 = performance.now(); + const memAfter100k = process.memoryUsage().heapUsed; + const repair100kDuration = repair100kT1 - repair100kT0; + const heapDelta100k = memAfter100k - memBefore100k; + + console.log( + ` 100k Repair duration: ${repair100kDuration.toFixed(2)} ms (order updates: ${repair100kRes.orderSequencesUpdated}, bill updates: ${repair100kRes.billSequencesUpdated})`, + ); + console.log(` 100k Heap delta: ${(heapDelta100k / 1024).toFixed(1)} KB`); + + assertEqualOrThrow( + repair100kRes.orderSequencesUpdated, + 1000, + 'All 1,000 order date buckets seeded', + ); + assertEqualOrThrow( + repair100kRes.billSequencesUpdated, + 1000, + 'All 1,000 bill date buckets seeded', + ); + assertOrThrow( + heapDelta100k < 2 * 1024 * 1024, + `Heap delta must be < 2 MB, was ${(heapDelta100k / 1024).toFixed(1)} KB`, + ); + + // Verify sample sequences match maximums (100) + const sampleSeq100k = db + .prepare("SELECT current_value FROM sequences WHERE name = 'orders' AND date = '20100500'") + .get() as any; + assertEqualOrThrow( + sampleSeq100k?.current_value, + 100, + 'Sample date 20100500 has high-water 100', + ); + + // Test ordinary boot fast-path over 100k dataset + const fastBoot100kT0 = performance.now(); + const fastBoot100kRes = repairSequences(false); + const fastBoot100kT1 = performance.now(); + const fastBoot100kDuration = fastBoot100kT1 - fastBoot100kT0; + + console.log(` 100k Ordinary boot duration: ${fastBoot100kDuration.toFixed(3)} ms`); + assertEqualOrThrow(fastBoot100kRes.repaired, false, 'Ordinary boot skips scanning 100k rows'); + assertOrThrow( + fastBoot100kDuration < 15, + `Ordinary boot must take < 15 ms, took ${fastBoot100kDuration.toFixed(2)} ms`, + ); + + // ───────────────────────────────────────────────────────────────────────── + // Section 5: Scale Benchmark at 365k Rows (10 Years of Data) + // ───────────────────────────────────────────────────────────────────────── + console.log('\n--- 5. Scale Benchmark: 365,000 Orders & 365,000 Bills ---'); + + console.log(' Expanding dataset to 365,000 orders across 3,650 dates (10 years)...'); + const seed365kT0 = performance.now(); + db.transaction(() => { + for (let d = 1001; d <= 3650; d++) { + const dateStr = '2010' + String(d).padStart(4, '0'); + for (let i = 1; i <= 100; i++) { + const sfx = String(i).padStart(4, '0'); + const orderRes = insertOrder.run(`ORD-${dateStr}-${sfx}`); + insertBill.run(orderRes.lastInsertRowid, `INV-${dateStr}-${sfx}`); + } + } + })(); + const seed365kT1 = performance.now(); + console.log(` Expanded to 365k in ${(seed365kT1 - seed365kT0).toFixed(1)} ms`); + + const orderCount = (db.prepare('SELECT count(*) as count FROM orders').get() as any).count; + assertEqualOrThrow(orderCount, 365000, 'Orders count is exactly 365,000'); + + // Benchmark forced repair over 365,000 rows + const memBefore365k = process.memoryUsage().heapUsed; + const repair365kT0 = performance.now(); + const repair365kRes = repairSequences(true); + const repair365kT1 = performance.now(); + const memAfter365k = process.memoryUsage().heapUsed; + const repair365kDuration = repair365kT1 - repair365kT0; + const heapDelta365k = memAfter365k - memBefore365k; + + console.log(` 365k Repair duration: ${repair365kDuration.toFixed(2)} ms`); + console.log(` 365k Heap delta: ${(heapDelta365k / 1024).toFixed(1)} KB`); + + assertOrThrow( + repair365kDuration < 1500, + `Repair duration must be < 1500 ms, took ${repair365kDuration.toFixed(2)} ms`, + ); + assertOrThrow( + heapDelta365k < 2 * 1024 * 1024, + `Heap delta must be < 2 MB, was ${(heapDelta365k / 1024).toFixed(1)} KB`, + ); + + // Verify sample date in newly added range + const sample365k = db + .prepare("SELECT current_value FROM sequences WHERE name = 'orders' AND date = '20102500'") + .get() as any; + assertEqualOrThrow( + sample365k?.current_value, + 100, + 'Sample 365k order sequence has high-water 100', + ); + + // Test ordinary boot fast-path over 365k dataset + const fastBoot365kT0 = performance.now(); + const fastBoot365kRes = repairSequences(false); + const fastBoot365kT1 = performance.now(); + const fastBoot365kDuration = fastBoot365kT1 - fastBoot365kT0; + + console.log(` 365k Ordinary boot duration: ${fastBoot365kDuration.toFixed(3)} ms`); + assertEqualOrThrow(fastBoot365kRes.repaired, false, 'Ordinary boot skips scanning 365k rows'); + assertOrThrow( + fastBoot365kDuration < 15, + `Ordinary boot must take < 15 ms, took ${fastBoot365kDuration.toFixed(2)} ms`, + ); + + console.log('\n' + '='.repeat(60)); + console.log('✓ All sequence repair performance and correctness tests passed!'); + console.log('='.repeat(60)); + } finally { + closeDatabase(); + try { + fs.rmSync(testDir, { recursive: true, force: true }); + } catch {} + } +} + +main().catch((err) => { + console.error('Test failed with error:', err); + process.exit(1); +});