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
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ Router: `main/routes/database.ts`. Full path: `/api/db`.
| Method | Path | Authorization | Parameters | Response |
| --- | --- | --- | --- | --- |
| `GET` | `/export` | `ROLE_ACCESS.owner` | none | Full database snapshot as a downloadable JSON attachment. |
| `POST` | `/import` | `ROLE_ACCESS.owner` + Master PIN when `overwrite` or a schema-version mismatch | body: `data`, `overwrite` | Body `data` plus `overwrite`. Master PIN is required when `overwrite` is set or the snapshot's `schema_version` differs from the local one. |
| `POST` | `/import` | `ROLE_ACCESS.owner` + Master PIN when `overwrite` or a schema-version mismatch | body: `data`, `overwrite` | Body `data` plus `overwrite`. Master PIN is required when `overwrite` is set or the snapshot's `schema_version` differs from the local one. Re-aligns sequence counters, marks sequence repair complete, and rebuilds search indexes before commit. |
| `POST` | `/backup` | `ROLE_ACCESS.owner` + Master PIN | none | - |
| `GET` | `/download` | `ROLE_ACCESS.owner` + Master PIN | none | - |
| `GET` | `/tables` | `ROLE_ACCESS.owner` | none | `{ tables: [ ... ] }`, every non-internal SQLite table name. |
Expand Down
12 changes: 12 additions & 0 deletions main/routes/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getDbPath,
createBackup,
createBackupUnlocked,
executeSequenceRepair,
getCurrentSchemaVersion,
getForeignKeyViolationKeys,
getInventoryMovementRows,
Expand All @@ -21,6 +22,7 @@ import {
mergeUserSecurityState,
mergeUserStationSecurityState,
now,
rebuildSearchIndex,
restoreBackup,
throwIfDatabaseMaintenanceAborted,
validateInventoryLedgerDatabase,
Expand Down Expand Up @@ -597,6 +599,16 @@ router.post(
}
throwIfDatabaseMaintenanceAborted(signal);
clearGoogleDriveRestoreBinding(db);
executeSequenceRepair(db);
db.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());
db.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());
rebuildSearchIndex(db);
db.exec('COMMIT');
const cleanup = googleDrive.completeDatabaseRestore();
try {
Expand Down
156 changes: 156 additions & 0 deletions tests/database-tools-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const {
MIGRATIONS,
now,
validateInventoryLedgerDatabase,
generateOrderNumber,
generateBillNumber,
repairSequences,
} = require('../main/db');
const { getJWTSecret } = require('../main/routes/auth');
const { authRoutes } = require('../main/routes/auth');
Expand Down Expand Up @@ -1752,6 +1755,159 @@ async function runTests() {
);
}

// ── Test 3d: sequence repair on partial import without sequences table ──
console.log(
'\nTest 3d: POST /db/import repairs sequences when importing orders/bills without sequences',
);
{
const insertSetting = (key: string, value: string) =>
db
.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)')
.run(key, value, now());
insertSetting('country', 'IN');
insertSetting('currency', 'INR');
insertSetting('timezone', 'Asia/Kolkata');

const initialOrderNum = generateOrderNumber();
const initialBillNum = generateBillNumber();
const ordResult = db
.prepare('INSERT INTO orders (order_number, created_at) VALUES (?, ?)')
.run(initialOrderNum, now());
const initialOrderId = ordResult.lastInsertRowid;
db.prepare(
'INSERT INTO bills (order_id, bill_number, subtotal, tax_amount, total, paid_amount, balance, created_at) VALUES (?, ?, 100, 0, 100, 100, 0, ?)',
).run(initialOrderId, initialBillNum, now());

const orderDate = initialOrderNum.split('-')[1];
const billDate = initialBillNum.split('-')[1];

const partialImport = await request(app)
.post('/api/db/import')
.set('Authorization', `Bearer ${ownerToken}`)
.send({
overwrite: false,
data: {
schema_version: String(getCurrentSchemaVersion()),
data: {
settings: [],
categories: [],
products: [],
users: [],
orders: [
{ id: 102, order_number: `ORD-${orderDate}-0002`, created_at: now() },
{ id: 103, order_number: `ORD-${orderDate}-0003`, created_at: now() },
{ id: 104, order_number: `ORD-${orderDate}-0004`, created_at: now() },
{ id: 105, order_number: `ORD-${orderDate}-0005`, created_at: now() },
],
bills: [
{
id: 102,
order_id: 102,
bill_number: `INV-${billDate}-0002`,
subtotal: 10,
tax_amount: 0,
total: 10,
paid_amount: 10,
balance: 0,
created_at: now(),
},
{
id: 103,
order_id: 103,
bill_number: `INV-${billDate}-0003`,
subtotal: 10,
tax_amount: 0,
total: 10,
paid_amount: 10,
balance: 0,
created_at: now(),
},
{
id: 104,
order_id: 104,
bill_number: `INV-${billDate}-0004`,
subtotal: 10,
tax_amount: 0,
total: 10,
paid_amount: 10,
balance: 0,
created_at: now(),
},
{
id: 105,
order_id: 105,
bill_number: `INV-${billDate}-0005`,
subtotal: 10,
tax_amount: 0,
total: 10,
paid_amount: 10,
balance: 0,
created_at: now(),
},
],
},
},
});

assert(partialImport.status === 200, `partial import succeeds (got ${partialImport.status})`);

const orderSeq = db
.prepare("SELECT current_value FROM sequences WHERE name = 'orders' AND date = ?")
.get(orderDate) as { current_value: number } | undefined;
assertEqual(orderSeq?.current_value, 5, 'orders sequence counter advanced to 5 after import');

const billSeq = db
.prepare("SELECT current_value FROM sequences WHERE name = 'bills' AND date = ?")
.get(billDate) as { current_value: number } | undefined;
assertEqual(billSeq?.current_value, 5, 'bills sequence counter advanced to 5 after import');

const nextOrderNum = generateOrderNumber();
assertEqual(nextOrderNum, `ORD-${orderDate}-0006`, 'next generated order number is 0006');
let orderInsertThrew = false;
try {
db.prepare('INSERT INTO orders (order_number, created_at) VALUES (?, ?)').run(
nextOrderNum,
now(),
);
} catch {
orderInsertThrew = true;
}
assert(!orderInsertThrew, 'creating next order succeeds without UNIQUE constraint failure');

const nextBillNum = generateBillNumber();
assertEqual(nextBillNum, `INV-${billDate}-0006`, 'next generated bill number is 0006');
let billInsertThrew = false;
try {
db.prepare(
'INSERT INTO bills (order_id, bill_number, subtotal, tax_amount, total, paid_amount, balance, created_at) VALUES (105, ?, 10, 0, 10, 10, 0, ?)',
).run(nextBillNum, now());
} catch {
billInsertThrew = true;
}
assert(!billInsertThrew, 'creating next bill succeeds without UNIQUE constraint failure');

// Simulate reopen fast-path
const bootRepair = repairSequences(false);
assertEqual(bootRepair.repaired, false, 'reopen fast-path skips historical scan');

const nextOrderNumAfterReopen = generateOrderNumber();
assertEqual(
nextOrderNumAfterReopen,
`ORD-${orderDate}-0007`,
'next order number after reopen is 0007',
);
let orderReopenThrew = false;
try {
db.prepare('INSERT INTO orders (order_number, created_at) VALUES (?, ?)').run(
nextOrderNumAfterReopen,
now(),
);
} catch {
orderReopenThrew = true;
}
assert(!orderReopenThrew, 'creating order after reopen succeeds');
}

// ── Test 4: POST /db-tools/initialize ────────────────────────────────────
console.log('\nTest 4: POST /db-tools/initialize');
{
Expand Down
Loading