From 67f7b45dc00bd788ea13b28ada87b1018bc38d40 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 12:21:31 -0400 Subject: [PATCH 1/2] fix: preserve local backup opt-out and pre-reset retention across resets and restores --- docs/architecture/data-and-migrations.md | 6 +- docs/reference/api.md | 2 +- main/db.ts | 33 ++- main/ipc.ts | 19 +- main/routes/database-tools.ts | 8 + tests/local-backup.test.ts | 247 ++++++++++++++++++++++- 6 files changed, 305 insertions(+), 10 deletions(-) diff --git a/docs/architecture/data-and-migrations.md b/docs/architecture/data-and-migrations.md index eb7f1c274..183f46c1a 100644 --- a/docs/architecture/data-and-migrations.md +++ b/docs/architecture/data-and-migrations.md @@ -169,8 +169,10 @@ ahead of the clock rank below every other file because their time cannot be trus written is always kept, and the newest `LOCAL_BACKUP_RETENTION` (5) are kept in total. Failures are logged and retried on the next check. These files are plain SQLite and unencrypted. They are not part of `listBackups()`; restore one through `POST /api/db/restore` with the file picker. -`POST /api/db-tools/initialize` also copies its pre-reset backup here (`retainBackupLocally`) when the -setting is on. +`POST /api/db-tools/initialize`, `POST /api/db-tools/currency-reset`, and the `db-initialize` IPC +channel preserve `local_backup_enabled` across the reset and copy their pre-reset backup here +(`retainBackupLocally`) when the setting is on; `restoreBackup()` likewise preserves the live +`local_backup_enabled` setting across a restore. ### Durability diff --git a/docs/reference/api.md b/docs/reference/api.md index dff720c7f..3aecca90f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -825,7 +825,7 @@ Router: `main/routes/database-tools.ts`. Full path: `/api/db-tools`. | `GET` | `/master-pin/status` | `ROLE_ACCESS.owner` | none | - | | `POST` | `/master-pin/reset` | `ROLE_ACCESS.owner` (`database.manage`) + current Master PIN when one is configured | body: `pin`, `confirm_pin`, and `master_pin` when a PIN is already configured | First-time setup requires no `master_pin`. | | `GET` | `/currency-reset-impact` | `ROLE_ACCESS.owner` | none | Active currency plus the invoice, order, refund, customer, product, and add-on counts the destructive warning uses. | -| `POST` | `/currency-reset` | `ROLE_ACCESS.owner` + Master PIN | body: `currency` | Also needs `current_currency` and `confirmation_phrase` of the form `CHANGE TO `. Creates a recovery backup, recreates the local database, preserves the sanitized menu catalog with monetary fields zeroed, and returns the backup path. The active session becomes invalid. | +| `POST` | `/currency-reset` | `ROLE_ACCESS.owner` + Master PIN | body: `currency` | Also needs `current_currency` and `confirmation_phrase` of the form `CHANGE TO `. Creates a recovery backup, recreates the local database, preserves `local_backup_enabled` and the sanitized menu catalog with monetary fields zeroed, and returns `{ success, currency, backupPath, cleanupPending, localBackupFailed }`; `localBackupFailed` is `true` when the daily local backup is on but the pre-reset copy could not be kept there. The active session becomes invalid. | | `POST` | `/initialize` | `ROLE_ACCESS.owner` + Master PIN | body: `confirmation_phrase` | A stale Drive restore boundary classified as ambiguous by recovery may be discarded only by this owner-authorized reset with the exact `INITIALIZE` phrase (stored Drive credentials are dropped); an active in-process reset still conflicts. Returns `{ success, backupPath, cleanupPending, localBackupFailed }`; `localBackupFailed` is `true` when the daily local backup is on but the pre-reset copy could not be kept there, which never fails the committed reset. | ### Reports diff --git a/main/db.ts b/main/db.ts index 11931b639..9af632835 100644 --- a/main/db.ts +++ b/main/db.ts @@ -2031,7 +2031,13 @@ async function resetDatabaseWithBackupUnlocked( const { path: backupPath } = await createBackupUnlocked(undefined, maintenanceSignal); throwIfDatabaseMaintenanceAborted(maintenanceSignal); const dbPath = getDbPath(); - const baselineForeignKeyViolations = getForeignKeyViolationKeys(getDatabase()); + const currentDb = getDatabase(); + const baselineForeignKeyViolations = getForeignKeyViolationKeys(currentDb); + const localBackupEnabled = ( + currentDb + .prepare("SELECT value FROM settings WHERE key = 'local_backup_enabled'") + .get() as { value?: string } | undefined + )?.value; const recoveryPath = path.join( getBackupDir(), `flo-reset-recovery-${crypto.randomBytes(8).toString('hex')}.db`, @@ -2065,7 +2071,14 @@ async function resetDatabaseWithBackupUnlocked( initDatabase(false, true); await new Promise((resolve) => setImmediate(resolve)); throwIfDatabaseMaintenanceAborted(maintenanceSignal); - afterInit?.(getDatabase()); + const freshDb = getDatabase(); + if (localBackupEnabled !== undefined) { + freshDb + .prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)') + .run('local_backup_enabled', localBackupEnabled, now()); + settingsRevision += 1; + } + afterInit?.(freshDb); throwIfDatabaseMaintenanceAborted(maintenanceSignal); (db ?? getDatabase()).pragma('wal_checkpoint(TRUNCATE)'); syncFile(dbPath); @@ -2290,9 +2303,17 @@ export async function resetDatabaseForCurrencyChange( ): Promise<{ backupPath: string; committed?: boolean; cleanupPending?: boolean }> { return withDatabaseMaintenanceLock(async (maintenanceSignal) => { const currentDb = getDatabase(); + const PRESERVED_SETTINGS = [ + 'country', + 'currency', + 'timezone', + 'local_backup_enabled', + ] as const; const settingsRows = currentDb - .prepare("SELECT key, value FROM settings WHERE key IN ('country', 'currency', 'timezone')") - .all() as { key: string; value: string }[]; + .prepare( + `SELECT key, value FROM settings WHERE key IN (${PRESERVED_SETTINGS.map(() => '?').join(', ')})`, + ) + .all(...PRESERVED_SETTINGS) as { key: string; value: string }[]; const settings = Object.fromEntries(settingsRows.map((row) => [row.key, row.value])); if (settings.currency !== expectedCurrentCurrency || settings.currency === targetCurrency) { throw Object.assign(new Error('Active currency changed'), { code: 'ERR_CURRENCY_CHANGED' }); @@ -2315,6 +2336,9 @@ export async function resetDatabaseForCurrencyChange( writeSetting.run('currency', regional.currency, now()); writeSetting.run('currency_symbol', regional.currencySymbol, now()); writeSetting.run('timezone', regional.timezone, now()); + if (settings.local_backup_enabled !== undefined) { + writeSetting.run('local_backup_enabled', settings.local_backup_enabled, now()); + } freshDb.prepare('INSERT OR REPLACE INTO _flo_meta (key, value) VALUES (?, ?)').run( 'currency_reset_pending', JSON.stringify({ @@ -3097,6 +3121,7 @@ const RESTORE_PROTECTED_SETTING_KEYS = [ 'cloud_device_secret', 'cloud_pos_hash', 'offline_mode', + 'local_backup_enabled', 'telemetry_enabled', 'product_analytics_level', 'diagnostics_consent', diff --git a/main/ipc.ts b/main/ipc.ts index 1f892e841..75da3b150 100644 --- a/main/ipc.ts +++ b/main/ipc.ts @@ -45,6 +45,11 @@ import { isSafeWhatsAppShareUrl } from './security/url-allowlist'; import log from 'electron-log/main'; import { randomUUID } from 'crypto'; import { cloudSync } from './services/cloud-sync'; +import { + isLocalBackupEnabled, + retainBackupLocally, + setLocalBackupEnabled, +} from './services/local-backup'; import { readPostHogBootstrap } from './services/posthog'; // Cap on the log content attached to a support ticket (most recent bytes only). @@ -449,12 +454,24 @@ export function registerIpcHandlers( // never resolve on its own; the reset invalidates Drive state either way. await googleDrive.prepareForDatabaseRestore({ discardUnresolvedBoundary: true }); restorePrepared = true; + const keepLocalBackup = isLocalBackupEnabled(); const { backupPath } = await resetDatabaseWithBackup(shutdownSignal); + let localBackupFailed = false; + if (keepLocalBackup) { + localBackupFailed = retainBackupLocally(backupPath) === null; + } else { + setLocalBackupEnabled(false); + } const cleanup = googleDrive.completeDatabaseRestore(); clearUserAuthCache(); clearInMemoryRevokedTokens(); clearJWTSecretCache(); - return { success: true, backupPath, cleanupPending: cleanup.cleanupPending }; + return { + success: true, + backupPath, + cleanupPending: cleanup.cleanupPending, + localBackupFailed, + }; } catch (error: unknown) { console.error('[IPC] db-initialize: Error:', error); return { success: false, error: getErrorMessage(error) }; diff --git a/main/routes/database-tools.ts b/main/routes/database-tools.ts index 55971d13d..4676fce47 100644 --- a/main/routes/database-tools.ts +++ b/main/routes/database-tools.ts @@ -228,11 +228,18 @@ router.post( try { await googleDrive.prepareForDatabaseRestore(); restorePrepared = true; + const keepLocalBackup = isLocalBackupEnabled(); const result = await resetDatabaseForCurrencyChange( currency, impact.currentCurrency, getHttpRequestSignal(req), ); + let localBackupFailed = false; + if (keepLocalBackup) { + localBackupFailed = retainBackupLocally(result.backupPath) === null; + } else { + setLocalBackupEnabled(false); + } const cleanup = googleDrive.completeDatabaseRestore(); clearUserAuthCache(); clearInMemoryRevokedTokens(); @@ -242,6 +249,7 @@ router.post( currency, backupPath: result.backupPath, cleanupPending: result.cleanupPending || cleanup.cleanupPending, + localBackupFailed, }); } catch (error: unknown) { if ( diff --git a/tests/local-backup.test.ts b/tests/local-backup.test.ts index 35be01796..ccffa3a3b 100644 --- a/tests/local-backup.test.ts +++ b/tests/local-backup.test.ts @@ -12,6 +12,16 @@ fs.mkdirSync(documentsDir, { recursive: true }); fs.mkdirSync(userDataDir, { recursive: true }); let documentsThrows = false; +const ipcHandlers = new Map any>(); +const mainFrame = { + frameToken: 'main-frame-token', + detached: false, + url: 'http://localhost:3001/', +}; +const mainWin = { + isDestroyed: () => false, + webContents: { mainFrame, getURL: () => 'http://localhost:3001/' }, +}; Module._load = function (request: string, parent: unknown, isMain: boolean) { if (request === 'electron') { @@ -32,7 +42,16 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { encryptString: (value: string) => Buffer.from(value, 'utf8'), decryptString: (value: Buffer) => value.toString('utf8'), }, - ipcMain: { on: () => {}, handle: () => {}, removeHandler: () => {} }, + ipcMain: { + on: () => {}, + handle: (channel: string, listener: (...args: any[]) => any) => { + ipcHandlers.set(channel, listener); + }, + removeHandler: () => {}, + }, + BrowserWindow: { + fromWebContents: (sender: unknown) => (sender === mainWin.webContents ? mainWin : null), + }, dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, shell: { openExternal: () => Promise.resolve() }, }; @@ -43,7 +62,17 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { const express = require('express'); const request = require('supertest'); import Database from 'better-sqlite3'; -import { closeDatabase, getDatabase, initDatabase } from '../main/db'; +import { + closeDatabase, + createBackup, + getDatabase, + initDatabase, + now, + resetDatabaseForCurrencyChange, + resetDatabaseWithBackup, + restoreBackup, +} from '../main/db'; +import { registerIpcHandlers } from '../main/ipc'; import { databaseToolsRoutes } from '../main/routes/database-tools'; import { resetMasterPin } from '../main/services/master-pin'; import { @@ -347,6 +376,220 @@ async function main(): Promise { assert.equal(failedRes.body.localBackupFailed, true, 'a failed local copy is reported'); fs.rmSync(backupDir, { force: true }); + // Currency reset: preserves local_backup_enabled = 'false' (including when products survive) + // and retains the pre-reset backup locally when enabled. + const seedStoreForCurrencyReset = (currencyCode: string) => { + ensureOwner(); + const curDb = getDatabase(); + const stampValue = now(); + const writeSetting = curDb.prepare( + 'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)', + ); + writeSetting.run('country', 'US', stampValue); + writeSetting.run('currency', currencyCode, stampValue); + writeSetting.run('timezone', 'America/New_York', stampValue); + curDb + .prepare('INSERT OR IGNORE INTO categories (id, name) VALUES (?, ?)') + .run('c-reset', 'Coffee'); + curDb + .prepare('INSERT OR IGNORE INTO products (id, category_id, name, price) VALUES (?, ?, ?, ?)') + .run('p-reset', 'c-reset', 'Espresso', 3.5); + }; + + seedStoreForCurrencyReset('USD'); + setLocalBackupEnabled(false); + const currencyResetDisabledRes = await request(api).post('/api/db-tools/currency-reset').send({ + currency: 'EUR', + current_currency: 'USD', + confirmation_phrase: 'CHANGE TO EUR', + master_pin: '1234', + }); + assert.equal( + currencyResetDisabledRes.status, + 200, + 'currency-reset succeeds when local backup is disabled', + ); + assert.equal(currencyResetDisabledRes.body.localBackupFailed, false); + assert.equal( + isLocalBackupEnabled(), + false, + 'local_backup_enabled remains false after POST /api/db-tools/currency-reset', + ); + assert.deepEqual( + listLocalBackups(backupDir), + [], + 'currency-reset does not retain a local backup when disabled', + ); + await localBackup.runIfDue(); + assert.deepEqual( + listLocalBackups(backupDir), + [], + 'runIfDue does not write a backup after currency-reset when opted out even with preserved products', + ); + + // Direct resetDatabaseForCurrencyChange call also preserves local_backup_enabled = 'false' + seedStoreForCurrencyReset('EUR'); + setLocalBackupEnabled(false); + await resetDatabaseForCurrencyChange('GBP', 'EUR'); + assert.equal( + isLocalBackupEnabled(), + false, + 'local_backup_enabled remains false after direct resetDatabaseForCurrencyChange', + ); + await localBackup.runIfDue(); + assert.deepEqual(listLocalBackups(backupDir), []); + + // Currency reset when local_backup_enabled = 'true' retains the pre-reset backup locally + seedStoreForCurrencyReset('GBP'); + setLocalBackupEnabled(true); + const currencyResetEnabledRes = await request(api).post('/api/db-tools/currency-reset').send({ + currency: 'CAD', + current_currency: 'GBP', + confirmation_phrase: 'CHANGE TO CAD', + master_pin: '1234', + }); + assert.equal(currencyResetEnabledRes.status, 200); + assert.equal(currencyResetEnabledRes.body.localBackupFailed, false); + assert.equal( + listLocalBackups(backupDir).length, + 1, + 'POST /api/db-tools/currency-reset retains the pre-reset backup locally when enabled', + ); + for (const name of listLocalBackups(backupDir)) { + fs.unlinkSync(path.join(backupDir, name)); + } + + // Direct resetDatabaseWithBackup and Electron IPC db-initialize preserve opt-out and retain when enabled + setLocalBackupEnabled(false); + await resetDatabaseWithBackup(); + assert.equal( + isLocalBackupEnabled(), + false, + 'local_backup_enabled remains false after direct resetDatabaseWithBackup', + ); + + registerIpcHandlers(undefined, () => mainWin as any); + const dbInitializeHandler = ipcHandlers.get('db-initialize'); + assert.ok(dbInitializeHandler, 'db-initialize IPC handler is registered'); + const trustedIpcEvent = { sender: mainWin.webContents, senderFrame: mainFrame }; + + setLocalBackupEnabled(false); + getDatabase() + .prepare('INSERT INTO categories (id, name) VALUES (?, ?)') + .run('c-ipc', 'Bakery'); + getDatabase() + .prepare('INSERT INTO products (id, category_id, name, price) VALUES (?, ?, ?, ?)') + .run('p-ipc', 'c-ipc', 'Croissant', 4); + const ipcDisabledRes = await dbInitializeHandler(trustedIpcEvent, { + pin: '1234', + confirmationPhrase: 'INITIALIZE', + }); + assert.equal(ipcDisabledRes.success, true, 'IPC db-initialize succeeds when disabled'); + assert.equal(ipcDisabledRes.localBackupFailed, false); + assert.equal( + isLocalBackupEnabled(), + false, + 'local_backup_enabled remains false after IPC db-initialize', + ); + assert.deepEqual( + listLocalBackups(backupDir), + [], + 'IPC db-initialize does not retain a local backup when disabled', + ); + getDatabase() + .prepare('INSERT INTO categories (id, name) VALUES (?, ?)') + .run('c-ipc-after', 'Bakery'); + getDatabase() + .prepare('INSERT INTO products (id, category_id, name, price) VALUES (?, ?, ?, ?)') + .run('p-ipc-after', 'c-ipc-after', 'Croissant', 4); + await localBackup.runIfDue(); + assert.deepEqual( + listLocalBackups(backupDir), + [], + 'runIfDue does not write a backup after IPC db-initialize when opted out', + ); + + setLocalBackupEnabled(true); + const ipcEnabledRes = await dbInitializeHandler(trustedIpcEvent, { + pin: '1234', + confirmationPhrase: 'INITIALIZE', + }); + assert.equal(ipcEnabledRes.success, true, 'IPC db-initialize succeeds when enabled'); + assert.equal(ipcEnabledRes.localBackupFailed, false); + assert.equal( + listLocalBackups(backupDir).length, + 1, + 'IPC db-initialize retains the pre-reset backup locally when enabled', + ); + for (const name of listLocalBackups(backupDir)) { + fs.unlinkSync(path.join(backupDir, name)); + } + + // Backup restore (direct and data-only) preserves local_backup_enabled = 'false' + getDatabase() + .prepare('INSERT INTO categories (id, name) VALUES (?, ?)') + .run('c-restore', 'Drinks'); + getDatabase() + .prepare('INSERT INTO products (id, category_id, name, price) VALUES (?, ?, ?, ?)') + .run('p-restore', 'c-restore', 'Americano', 3); + setLocalBackupEnabled(true); + const snapshotWithEnabled = await createBackup(path.join(testDir, 'snapshot-enabled.db')); + + setLocalBackupEnabled(false); + const directRestoreRes = restoreBackup(snapshotWithEnabled.path, true); + assert.equal(directRestoreRes.success, true, 'direct restore succeeds'); + assert.equal(directRestoreRes.mode, 'direct'); + assert.equal( + isLocalBackupEnabled(), + false, + 'local_backup_enabled remains false after direct restoreBackup of an enabled backup', + ); + await localBackup.runIfDue(); + assert.deepEqual( + listLocalBackups(backupDir), + [], + 'runIfDue does not write a backup after direct restoreBackup when opted out', + ); + + const snapshotWithoutRowPath = path.join(testDir, 'snapshot-no-row.db'); + fs.copyFileSync(snapshotWithEnabled.path, snapshotWithoutRowPath); + const noRowDb = new Database(snapshotWithoutRowPath); + try { + noRowDb.prepare("DELETE FROM settings WHERE key = 'local_backup_enabled'").run(); + } finally { + noRowDb.close(); + } + const noRowRestoreRes = restoreBackup(snapshotWithoutRowPath, true); + assert.equal(noRowRestoreRes.success, true, 'restore of backup without local_backup_enabled row succeeds'); + assert.equal( + isLocalBackupEnabled(), + false, + 'local_backup_enabled remains false after restoring a backup that had no local_backup_enabled row', + ); + + const olderSnapshotPath = path.join(testDir, 'snapshot-older.db'); + fs.copyFileSync(snapshotWithEnabled.path, olderSnapshotPath); + const olderDb = new Database(olderSnapshotPath); + try { + const olderVersion = Number(olderDb.pragma('user_version', { simple: true })) - 1; + olderDb.pragma(`user_version = ${olderVersion}`); + olderDb + .prepare("UPDATE _flo_meta SET value = ? WHERE key = 'schema_version'") + .run(String(olderVersion)); + } finally { + olderDb.close(); + } + const dataOnlyRestoreRes = restoreBackup(olderSnapshotPath, false); + assert.equal(dataOnlyRestoreRes.success, true, 'data-only restore succeeds'); + assert.equal(dataOnlyRestoreRes.mode, 'data_only'); + assert.equal( + isLocalBackupEnabled(), + false, + 'local_backup_enabled remains false after data-only restoreBackup of an enabled backup', + ); + await localBackup.runIfDue(); + assert.deepEqual(listLocalBackups(backupDir), []); + await localBackup.stop(); closeDatabase(); fs.rmSync(testDir, { recursive: true, force: true }); From 22f0236573b355ea65fdb7761e41be4b47b8cbc4 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 12:47:02 -0400 Subject: [PATCH 2/2] no-mistakes(document): style: format local backup reset and restore changes --- main/db.ts | 13 ++++--------- tests/local-backup.test.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/main/db.ts b/main/db.ts index 9af632835..bf0f39d49 100644 --- a/main/db.ts +++ b/main/db.ts @@ -2034,9 +2034,9 @@ async function resetDatabaseWithBackupUnlocked( const currentDb = getDatabase(); const baselineForeignKeyViolations = getForeignKeyViolationKeys(currentDb); const localBackupEnabled = ( - currentDb - .prepare("SELECT value FROM settings WHERE key = 'local_backup_enabled'") - .get() as { value?: string } | undefined + currentDb.prepare("SELECT value FROM settings WHERE key = 'local_backup_enabled'").get() as + | { value?: string } + | undefined )?.value; const recoveryPath = path.join( getBackupDir(), @@ -2303,12 +2303,7 @@ export async function resetDatabaseForCurrencyChange( ): Promise<{ backupPath: string; committed?: boolean; cleanupPending?: boolean }> { return withDatabaseMaintenanceLock(async (maintenanceSignal) => { const currentDb = getDatabase(); - const PRESERVED_SETTINGS = [ - 'country', - 'currency', - 'timezone', - 'local_backup_enabled', - ] as const; + const PRESERVED_SETTINGS = ['country', 'currency', 'timezone', 'local_backup_enabled'] as const; const settingsRows = currentDb .prepare( `SELECT key, value FROM settings WHERE key IN (${PRESERVED_SETTINGS.map(() => '?').join(', ')})`, diff --git a/tests/local-backup.test.ts b/tests/local-backup.test.ts index ccffa3a3b..562202e69 100644 --- a/tests/local-backup.test.ts +++ b/tests/local-backup.test.ts @@ -474,9 +474,7 @@ async function main(): Promise { const trustedIpcEvent = { sender: mainWin.webContents, senderFrame: mainFrame }; setLocalBackupEnabled(false); - getDatabase() - .prepare('INSERT INTO categories (id, name) VALUES (?, ?)') - .run('c-ipc', 'Bakery'); + getDatabase().prepare('INSERT INTO categories (id, name) VALUES (?, ?)').run('c-ipc', 'Bakery'); getDatabase() .prepare('INSERT INTO products (id, category_id, name, price) VALUES (?, ?, ?, ?)') .run('p-ipc', 'c-ipc', 'Croissant', 4); @@ -560,7 +558,11 @@ async function main(): Promise { noRowDb.close(); } const noRowRestoreRes = restoreBackup(snapshotWithoutRowPath, true); - assert.equal(noRowRestoreRes.success, true, 'restore of backup without local_backup_enabled row succeeds'); + assert.equal( + noRowRestoreRes.success, + true, + 'restore of backup without local_backup_enabled row succeeds', + ); assert.equal( isLocalBackupEnabled(), false,