fix(restore): preserve operator-meta settings across restore

Closes the chicken-and-egg where `restore_allow_force` (and its
auto-upgrade tracking flag) got overwritten on every restore by
whatever value happened to be in the backup. Net effect:

  1. Admin enables Force Restore (via tonight's default-ON migration
     edit, or hand-SQL on older installs).
  2. Restore runs successfully.
  3. Restored DB has `restore_allow_force = <backup's old value>`.
  4. Next restore attempt: "Force restore is not allowed by system
     settings" — admin needs the SQL workaround AGAIN.

Cure: snapshot a small list of operator-meta keys BEFORE the DROP
DATABASE (while we still have a working pool against the OLD DB),
then UPSERT them back AFTER the psql restore + migrate.latest.

The preserved set is intentionally narrow — currently just
`restore_allow_force` and `restore_allow_force_auto_upgraded`. These
are about how the operator wants the install to behave, not user-
facing state. Adding more keys is a one-line addition to the
PRESERVED_META_KEYS constant.

Survives both:
  - backup is OLDER than the operator's most recent setting change
  - backup is NEWER but had a different operator policy
Either way, the post-restore install reflects the LIVE operator
policy, not the backup's snapshot of it.
This commit is contained in:
Luca
2026-05-31 22:48:56 +02:00
parent 989b42c2f1
commit e0ace0864e
+57
View File
@@ -829,6 +829,36 @@ class RestoreService {
const { host, port, user, password, database } = knexConfig.connection; const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password }; const env = { ...process.env, PGPASSWORD: password };
// Snapshot operator-meta settings BEFORE the DROP so we can
// restore them after the psql load. These keys are about how
// the operator wants the install to behave (force-restore
// permission, auto-upgrade tracking), not user-facing state —
// they should NOT be overwritten by whatever values the backup
// happens to contain.
//
// Chicken-and-egg this closes: `restore_allow_force` defaults
// to true (post tonight's migration 032 edit), but every
// restore would overwrite it with whatever the backup carried.
// Admin sets it to true → restores → wakes up with the row
// back to whatever was in the backup. Two consecutive restores
// needed the SQL workaround again. With this snapshot/replay,
// the operator's policy persists across restores.
const PRESERVED_META_KEYS = [
'restore_allow_force',
'restore_allow_force_auto_upgraded',
];
let preservedMeta = [];
try {
preservedMeta = await db('app_settings')
.whereIn('setting_key', PRESERVED_META_KEYS)
.select('setting_key', 'setting_value', 'setting_type');
this.log('info', `Snapshotted ${preservedMeta.length} restore-meta setting(s) for post-restore replay`, {
keys: preservedMeta.map(r => r.setting_key),
});
} catch (err) {
this.log('warn', `Could not snapshot restore-meta settings (continuing): ${err.message}`);
}
// `psql` with no `-d` defaults to a database whose name matches // `psql` with no `-d` defaults to a database whose name matches
// the connecting user, NOT a maintenance DB. So on installs // the connecting user, NOT a maintenance DB. So on installs
// where the user's home DB doesn't exist (e.g. user=`picpeak`, // where the user's home DB doesn't exist (e.g. user=`picpeak`,
@@ -976,6 +1006,33 @@ END $$;`
this.log('info', 'Running database migrations...'); this.log('info', 'Running database migrations...');
await db.migrate.latest(); await db.migrate.latest();
// Replay the snapshotted operator-meta settings on top of the
// restored DB. UPSERT by setting_key — if the backup had the
// same key with a different value, we overwrite it; if the row
// doesn't exist in the backup, we insert it. Either way the
// operator's pre-restore policy survives.
if (preservedMeta.length > 0) {
try {
for (const row of preservedMeta) {
await db('app_settings')
.insert({
setting_key: row.setting_key,
setting_value: row.setting_value,
setting_type: row.setting_type || 'restore',
updated_at: new Date(),
})
.onConflict('setting_key')
.merge({
setting_value: row.setting_value,
updated_at: new Date(),
});
}
this.log('info', `Replayed ${preservedMeta.length} restore-meta setting(s) post-restore`);
} catch (err) {
this.log('warn', `Could not replay restore-meta settings (admin may need to re-set them): ${err.message}`);
}
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {