Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/architecture/data-and-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
179 changes: 119 additions & 60 deletions main/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
/**
* 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 };
}
}

Expand Down Expand Up @@ -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;
}[];
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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());
},
},
{
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading