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
6 changes: 4 additions & 2 deletions docs/architecture/data-and-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <CODE>`. 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 <CODE>`. 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
Expand Down
28 changes: 24 additions & 4 deletions main/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -2065,7 +2071,14 @@ async function resetDatabaseWithBackupUnlocked(
initDatabase(false, true);
await new Promise<void>((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);
Expand Down Expand Up @@ -2290,9 +2303,12 @@ 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' });
Expand All @@ -2315,6 +2331,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({
Expand Down Expand Up @@ -3097,6 +3116,7 @@ const RESTORE_PROTECTED_SETTING_KEYS = [
'cloud_device_secret',
'cloud_pos_hash',
'offline_mode',
'local_backup_enabled',
'telemetry_enabled',
'product_analytics_level',
'diagnostics_consent',
Expand Down
19 changes: 18 additions & 1 deletion main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) };
Expand Down
8 changes: 8 additions & 0 deletions main/routes/database-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -242,6 +249,7 @@ router.post(
currency,
backupPath: result.backupPath,
cleanupPending: result.cleanupPending || cleanup.cleanupPending,
localBackupFailed,
});
} catch (error: unknown) {
if (
Expand Down
Loading
Loading