feat: Admin Area System: Database Backup/Restore and Danger Zone 1.01
This commit is contained in:
@@ -343,7 +343,32 @@ export class RestoreService {
|
||||
}
|
||||
const db = new Database(targetPath);
|
||||
db.pragma('foreign_keys = OFF');
|
||||
const capturedTriggerSqls: string[] = [];
|
||||
try {
|
||||
// Capture and temporarily drop the deployment-wide last-admin triggers.
|
||||
// SQLite triggers fire regardless of foreign_keys, so clearing the
|
||||
// sole admin row before inserting the archive's admin would abort the
|
||||
// rebuild. We only mutate an offline candidate file and reinstate
|
||||
// them before swap eligibility.
|
||||
const triggerRows = db
|
||||
.prepare(
|
||||
`SELECT name, sql FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete') AND sql IS NOT NULL`,
|
||||
)
|
||||
.all() as Array<{ name: string; sql: string }>;
|
||||
const capturedTriggerNames = new Set(triggerRows.map((r) => r.name));
|
||||
if (
|
||||
!capturedTriggerNames.has('trg_user_last_admin_update') ||
|
||||
!capturedTriggerNames.has('trg_user_last_admin_delete')
|
||||
) {
|
||||
throw new Error(
|
||||
'Required LAST_ADMIN triggers missing from cloned schema; refusing to rebuild',
|
||||
);
|
||||
}
|
||||
for (const row of triggerRows) {
|
||||
capturedTriggerSqls.push(row.sql);
|
||||
db.prepare(`DROP TRIGGER IF EXISTS "${row.name}"`).run();
|
||||
}
|
||||
|
||||
// Discover the tables present in the live schema; only these may be
|
||||
// restored. This protects against accidentally injecting unknown
|
||||
// table rows into the live system.
|
||||
@@ -384,10 +409,55 @@ export class RestoreService {
|
||||
});
|
||||
tx(rows);
|
||||
}
|
||||
} finally {
|
||||
|
||||
// Re-establish the deployment-wide invariant in the candidate
|
||||
// database before swap eligibility. A restore that ships zero
|
||||
// admins must fail here so the live system never lands in that
|
||||
// state, even momentarily.
|
||||
const adminCount = (
|
||||
db.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE "role" = 'admin'`).get() as { c: number }
|
||||
).c;
|
||||
if (!adminCount || adminCount < 1) {
|
||||
throw new Error('Restored archive contains no admin user; refusing to swap');
|
||||
}
|
||||
|
||||
for (const sql of capturedTriggerSqls) {
|
||||
db.exec(sql);
|
||||
}
|
||||
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('foreign_key_check');
|
||||
db.close();
|
||||
} catch (innerErr) {
|
||||
// Make sure we never leak a candidate missing the triggers: re-create
|
||||
// them best-effort before rethrowing so the rolled-back candidate is
|
||||
// not left in an unsafe state on disk.
|
||||
try {
|
||||
const existing = new Set(
|
||||
(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete')`,
|
||||
)
|
||||
.all() as Array<{ name: string }>
|
||||
).map((r) => r.name),
|
||||
);
|
||||
for (const sql of capturedTriggerSqls) {
|
||||
const m = /"([^"]+)"/.exec(sql);
|
||||
const name = m?.[1];
|
||||
if (name && !existing.has(name)) {
|
||||
db.exec(sql);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore best-effort restore errors
|
||||
}
|
||||
throw innerErr;
|
||||
} finally {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user