AI Implementation feature(918): Admin Area System: Database Backup/Restore and Danger Zone 1.01 (#63)

This commit was merged in pull request #63.
This commit is contained in:
2026-07-23 13:28:29 +00:00
parent e611d201d9
commit e2d6bb3d69
8 changed files with 382 additions and 163 deletions
@@ -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
}
}
}