feat: Admin Area System: Database Backup/Restore and Danger Zone 1.02

This commit is contained in:
OpenVelo Agent
2026-07-23 15:49:12 +00:00
parent 958b004452
commit 274db7c845
12 changed files with 1350 additions and 129 deletions
+67 -1
View File
@@ -1,6 +1,9 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import * as path from 'path';
import { FilesystemTransactionService } from '../modules/admin/system/filesystem-transaction.service';
import { resolveSystemStagingDir } from '../common/utils/upload';
@Injectable()
export class DatabaseInitService implements OnApplicationBootstrap {
@@ -10,22 +13,28 @@ export class DatabaseInitService implements OnApplicationBootstrap {
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly fsTx: FilesystemTransactionService,
) {}
/**
* Explicit, awaitable database initialization.
*
* - Recovers any interrupted filesystem swap transactions (durable
* manifest protocol) BEFORE touching the database or uploads.
* - Initializes the DataSource (idempotent — TypeORM returns the existing
* connection if already initialized).
* - Runs all pending migrations in order.
* - Verifies that the expected schema and seeded data are present.
*
* `main.ts` calls this BEFORE `app.listen()` so the HTTP server never
* accepts traffic until the DB is fully migrated and seeded.
* accepts traffic until the DB is fully migrated and seeded, and so the
* server never boots in a half-swapped state.
*/
async init(): Promise<void> {
if (this.initialized) return;
await this.recoverInterruptedSystemTransactions();
if (!this.dataSource.isInitialized) {
await this.dataSource.initialize();
}
@@ -57,6 +66,63 @@ export class DatabaseInitService implements OnApplicationBootstrap {
await this.init();
}
/**
* Resolve any durable filesystem transaction that was interrupted by a
* process death during a restore / danger-zone swap. Recovery runs
* before the TypeORM connection is opened so the application can never
* observe a half-applied swap.
*
* Steps:
* 1. Resolve every pending manifest under `<system-staging>` to a
* coherent on-disk state (restore old generation or finalize new).
* Throws and aborts startup if any manifest is ambiguous.
* 2. Sweep unowned rollback / restore-stage / wipe-stage artifacts
* that linger next to the live database and uploads directories,
* so a pre-existing half-finished state (such as the Job 919
* incident report) is also cleaned up.
*/
private async recoverInterruptedSystemTransactions(): Promise<void> {
const databasePath = path.resolve(
this.config.get<string>('DATABASE_PATH', './data/db.sqlite'),
);
const uploadDir = path.resolve(
this.config.get<string>('UPLOAD_DIR', './data/uploads'),
);
const stagingDir = resolveSystemStagingDir(this.config);
try {
const report = this.fsTx.recoverTransactions(stagingDir);
if (report.transactionsScanned > 0) {
this.logger.log(
`Filesystem transaction recovery: scanned=${report.transactionsScanned} resolved=${report.resolved} aborted=${report.aborted.length} cleaned=${report.cleaned.length}`,
);
}
} catch (err) {
this.logger.error(
`Filesystem transaction recovery failed: ${(err as Error).message}`,
);
throw err;
}
try {
const cleaned = this.fsTx.sweepUnownedArtifactsNearLivePaths(
[databasePath, uploadDir],
{ stagingDir },
);
if (cleaned.length > 0) {
this.logger.log(
`Removed unowned system artifacts on startup: ${cleaned.join(', ')}`,
);
}
} catch (err) {
// Sweeps are best-effort; if they fail we never abort startup
// because leftover artifacts do not break read access.
this.logger.warn(
`Unowned-artifact sweep failed: ${(err as Error).message}`,
);
}
}
/**
* Read PRAGMA journal_mode and switch to WAL if not already enabled.
* Persisted for file-backed databases; idempotent and safe on every
+2
View File
@@ -23,6 +23,7 @@ import { AddUserDeleteCascades1700000000700 } from './migrations/1700000000700-A
import { AddUserLastAdminTriggers1700000000800 } from './migrations/1700000000800-AddUserLastAdminTriggers';
import { AddAdminOperationTokens1700000000900 } from './migrations/1700000000900-AddAdminOperationTokens';
import { DatabaseInitService } from './database-init.service';
import { FilesystemTransactionModule } from '../modules/admin/system/filesystem-transaction.module';
const ENTITIES = [
UserEntity,
@@ -52,6 +53,7 @@ const MIGRATIONS = [
@Global()
@Module({
imports: [
FilesystemTransactionModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
@@ -5,7 +5,7 @@ import { AdminSystemController } from './admin-system.controller';
import { BackupService } from './backup.service';
import { RestoreService } from './restore.service';
import { DangerZoneService } from './danger-zone.service';
import { FilesystemTransactionService } from './filesystem-transaction.service';
import { FilesystemTransactionModule } from './filesystem-transaction.module';
import { ConfirmationTokenService } from './confirmation-token.service';
import { AuthModule } from '../../auth/auth.module';
@@ -13,20 +13,19 @@ import { AuthModule } from '../../auth/auth.module';
imports: [
TypeOrmModule.forFeature([AdminOperationTokenEntity]),
AuthModule,
FilesystemTransactionModule,
],
controllers: [AdminSystemController],
providers: [
BackupService,
RestoreService,
DangerZoneService,
FilesystemTransactionService,
ConfirmationTokenService,
],
exports: [
BackupService,
RestoreService,
DangerZoneService,
FilesystemTransactionService,
ConfirmationTokenService,
],
})
@@ -132,13 +132,39 @@ export class BackupService {
}
}
/**
* Subdirectory/file names that are part of the platform's own internal
* staging machinery rather than user-uploaded content. They MUST be
* excluded from backups so a partially failed swap cannot end up as
* application upload content in the resulting archive.
*/
private static readonly EXCLUDED_UPLOAD_ENTRIES: ReadonlySet<string> = new Set([
'.staging',
]);
private static readonly EXCLUDED_UPLOAD_PATTERNS: RegExp[] = [
/^\.?[^/]+\.rollback-[\w-]+$/, // e.g. .uploads.rollback-1784819568666-n0w53hxp
/^.+\.restore-stage-[\w-]+$/, // e.g. uploads.restore-stage-12345-abcdef
/^\.challenges\.wipe-stage-[\w-]+$/, // e.g. .challenges.wipe-stage-12345
];
private isExcludedUploadEntry(name: string): boolean {
if (BackupService.EXCLUDED_UPLOAD_ENTRIES.has(name)) return true;
for (const pattern of BackupService.EXCLUDED_UPLOAD_PATTERNS) {
if (pattern.test(name)) return true;
}
return false;
}
private async collectUploads(): Promise<BackupFileEntry[]> {
const out: BackupFileEntry[] = [];
if (!fs.existsSync(this.uploadDir)) return out;
const visit = (dir: string) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === '.staging') continue;
if (BackupService.EXCLUDED_UPLOAD_ENTRIES.has(entry.name)) continue;
// For top-level + first-level descendants only, drop rollback-style artifacts.
const relative = path.relative(this.uploadDir, path.join(dir, entry.name)).split(path.sep);
if (relative.length <= 1 && this.isExcludedUploadEntry(entry.name)) continue;
const target = path.join(dir, entry.name);
if (entry.isDirectory()) {
visit(target);
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { FilesystemTransactionService } from './filesystem-transaction.service';
/**
* Hosts the durable filesystem swap service so it can be reused by both
* the admin system operations (which perform the swap) and database
* initialization (which performs the startup recovery sweep). Keeping
* the provider in its own module avoids a circular dependency between
* DatabaseModule and AdminSystemModule.
*/
@Module({
providers: [FilesystemTransactionService],
exports: [FilesystemTransactionService],
})
export class FilesystemTransactionModule {}
@@ -5,32 +5,45 @@ import { ApiError } from '../../../common/errors/api-error';
import { ERROR_CODES } from '../../../common/errors/error-codes';
export interface SwapPair {
/** Absolute path of the live filesystem location that will be swapped out. */
livePath: string;
/** Absolute path of the staged replacement that will be swapped in. */
stagedPath: string;
/**
* If true, the pair participates in the manifest's "legacy"/"primary"
* generation tally. The database and uploads are typically `true`, while
* sidecar files (e.g. `-wal`, `-shm`) are NOT — they are tracked as
* auxiliary, so a partial sidecar swap does not mark the parent
* generation as "incomplete".
*/
contributesToGeneration?: boolean;
/**
* Auxiliary sidecar suffixes to move with the live path. When the live
* path is replaced, any matching sidecars (e.g. `<live>.wal`) are
* moved into the rollback dir and back. When the staged replacement
* arrives, stale sidecars are removed so the new file is not paired
* with an old WAL.
*/
sidecarSuffixes?: string[];
}
export interface SwapRollbackHandle {
rolledBack: boolean;
pairs: Array<{ livePath: string; rollbackPath: string; stagedPath: string }>;
committed: boolean;
/** Public identifier of the durable transaction; only present when recoverability is enabled. */
transactionId?: string;
/**
* The exact per-transaction manifest directory created under the system
* staging root. Stored on the handle so that `commit()` and `rollback()`
* can clean the right record without re-deriving paths.
*/
manifestDir?: string;
pairs: Array<{ livePath: string; rollbackPath: string; stagedPath: string; sidecarSuffixes: string[] }>;
}
interface WorkerHooks {
/**
* Optional hook that runs once after `prepare()` (file staging) but
* before the live swap (e.g. database rebuild). Throw to abort the
* operation and immediately trigger rollback.
*/
beforeSwap?: () => Promise<void>;
/**
* Optional hook that runs after the swap is registered but before it
* is considered durable (e.g. final database open, verification).
* Throw to trigger rollback.
*/
afterSwap?: () => Promise<void>;
/**
* Optional hook that runs after finalization completes successfully.
* Throw to trigger rollback even from a successful operation.
*/
onCommit?: () => Promise<void>;
}
@@ -41,54 +54,135 @@ export interface StageSwapInput {
}
/**
* Swap a set of file/directory pairs in a crash-safe way. Each pair moves
* the existing live path to a rollback location, then renames the staged
* replacement into place. If any step fails (or one of the supplied hooks
* throws), the rollback path is restored to the live location exactly as
* it was before the operation began.
* Transaction phase values written to the durable manifest. Recovery
* inspects these to decide whether the system is in a coherent state.
*
* The class assumes all live/staged paths share the same filesystem so
* `rename` is atomic; cross-filesystem moves fall back to copy+unlink.
* Phase progression under success:
* prepared → live-snapshotted → replacements-installed → verified → committed
*
* Phase progression under in-process failure or rollback():
* any → rolled-back
*/
export type TransactionPhase =
| 'prepared'
| 'live-snapshotted'
| 'replacements-installed'
| 'verified'
| 'committed'
| 'rolled-back';
export interface TransactionManifest {
schemaVersion: 1;
transactionId: string;
operation: string;
createdAt: string;
updatedAt: string;
phase: TransactionPhase;
pairs: Array<{
livePath: string;
rollbackPath: string;
stagedPath: string;
contributesToGeneration: boolean;
sidecarSuffixes: string[];
}>;
}
export interface RecoveryReport {
transactionsScanned: number;
resolved: number;
aborted: string[];
cleaned: string[];
}
/**
* Crash-safe filesystem swap with a durable manifest.
*
* Each `stageSwap()` writes a small JSON manifest under the configured
* system staging root. The manifest records the live/staged/rollback
* paths and the current phase. If the process is killed mid-swap, the
* manifest is still on disk so startup recovery can resolve the
* transaction deterministically:
*
* - If the old generation (live + rollback trees) is fully present,
* restore it.
* - If the new generation (staged → live) is fully present AND was
* marked `verified`, finalize it and remove the rollback copy.
* - Otherwise, fail closed: the manifest is left on disk for an
* operator, and startup throws a `SYSTEM_RESTORE_RECOVERY_PENDING`
* error.
*
* The class also sweeps stale `*.rollback-*` artifacts in the configured
* live paths that are NOT associated with any live manifest (e.g. from
* a previous Job 919 incident) so the recovery itself never silently
* removes unowned files.
*/
@Injectable()
export class FilesystemTransactionService {
private readonly logger = new Logger(FilesystemTransactionService.name);
/** Manifest file name inside each per-transaction directory. */
static MANIFEST_FILENAME = 'manifest.json';
/**
* Apply the swap. Returns a rollback handle; call `commit()` after the
* rest of the operation (including database swap) is durable, or
* `rollback()` on any failure.
* Apply the swap. Returns a rollback handle.
*
* Behavior matches the original (in-memory) contract: success means
* the staged replacement now lives at `livePath`; failure rolls the
* pair back. The difference is that the swap is now also recorded in a
* durable manifest so a crashed process can finish or undo the
* operation on next startup.
*
* If the caller's environment doesn't configure a system staging root,
* the method falls back to the old in-memory behavior (still safe; just
* not recoverable across process death).
*/
async stageSwap(input: StageSwapInput): Promise<SwapRollbackHandle> {
async stageSwap(
input: StageSwapInput,
opts: { stagingDir?: string } = {},
): Promise<SwapRollbackHandle> {
const txDir = opts.stagingDir ? this.ensureTxDir(opts.stagingDir) : null;
const manifest: TransactionManifest | null = txDir
? this.makeInitialManifest(input, txDir)
: null;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const rolled: SwapRollbackHandle['pairs'] = [];
const cleanupRollbackOnly: string[] = [];
try {
// 1. Stage: snapshot each live path to a rollback location.
for (const pair of input.pairs) {
if (!pair.livePath) throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Missing live path', 500);
if (!pair.stagedPath) throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Missing staged path', 500);
const rollbackPath = FilesystemTransactionService.makeRollbackPath(pair.livePath, stamp);
if (fs.existsSync(pair.livePath)) {
fs.renameSync(pair.livePath, rollbackPath);
cleanupRollbackOnly.push(rollbackPath);
} else if (fs.existsSync(pair.stagedPath)) {
// Live path absent but staged exists — delete staged so we fail closed.
try {
fs.rmSync(pair.stagedPath, { recursive: true, force: true });
} catch {
// ignore
const sidecarSuffixes = pair.sidecarSuffixes ?? [];
// Move any sidecar files (e.g. -wal/-shm) into the rollback dir first so they cannot
// be left paired with the swapped-in live path.
for (const suffix of sidecarSuffixes) {
const liveSidecar = `${pair.livePath}${suffix}`;
const rollbackSidecar = `${rollbackPath}${suffix}`;
if (fs.existsSync(liveSidecar) && !fs.existsSync(rollbackSidecar)) {
try {
fs.renameSync(liveSidecar, rollbackSidecar);
} catch {
// Cross-device: best-effort copy+unlink.
FilesystemTransactionService.copyDirSync(liveSidecar, rollbackSidecar);
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
rolled.push({ livePath: pair.livePath, rollbackPath, stagedPath: pair.stagedPath });
if (fs.existsSync(pair.livePath)) {
fs.renameSync(pair.livePath, rollbackPath);
} else if (fs.existsSync(pair.stagedPath)) {
try { fs.rmSync(pair.stagedPath, { recursive: true, force: true }); } catch { /* ignore */ }
}
rolled.push({ livePath: pair.livePath, rollbackPath, stagedPath: pair.stagedPath, sidecarSuffixes });
}
if (manifest) this.updateManifest(manifest, txDir!, 'live-snapshotted');
if (input.hooks?.beforeSwap) {
try {
await input.hooks.beforeSwap();
} catch (err) {
throw err;
}
await input.hooks.beforeSwap();
}
// 2. Swap: rename each staged path into the live location.
@@ -101,37 +195,49 @@ export class FilesystemTransactionService {
try {
fs.renameSync(r.stagedPath, r.livePath);
} catch (err) {
// Cross-device fall-back: copy + unlink.
FilesystemTransactionService.copyDirSync(r.stagedPath, r.livePath);
try {
fs.rmSync(r.stagedPath, { recursive: true, force: true });
} catch {
// ignore
try { fs.rmSync(r.stagedPath, { recursive: true, force: true }); } catch { /* ignore */ }
}
// Stale sidecars that belonged to a previous live DB must NOT survive the swap:
// drop anything that matches and isn't one we tracked above.
for (const suffix of r.sidecarSuffixes) {
const liveSidecar = `${r.livePath}${suffix}`;
if (fs.existsSync(liveSidecar)) {
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
if (manifest) this.updateManifest(manifest, txDir!, 'replacements-installed');
if (input.hooks?.afterSwap) {
await input.hooks.afterSwap();
}
if (manifest) this.updateManifest(manifest, txDir!, 'verified');
return {
rolledBack: false,
committed: false,
transactionId: manifest?.transactionId,
manifestDir: txDir ?? undefined,
pairs: rolled,
};
} catch (err) {
await this.rollbackLocal({ rolledBack: false, pairs: rolled });
await this.rollbackLocal({ rolledBack: false, committed: false, pairs: rolled, transactionId: manifest?.transactionId });
if (manifest && txDir) {
try {
this.updateManifest(manifest, txDir, 'rolled-back');
} catch {
// ignore: best effort
}
}
throw err;
} finally {
// Successful rename leaves rollback artifacts; we keep them until
// commit() (or rollback() explicitly deletes them).
void cleanupRollbackOnly;
}
}
/**
* Restore the original live paths from their rollback snapshots. Safe
* to call multiple times.
* Restore the original live paths from their rollback snapshots. Safe to call multiple times.
*/
async rollback(handle: SwapRollbackHandle): Promise<void> {
if (!handle || handle.rolledBack) return;
@@ -146,10 +252,11 @@ export class FilesystemTransactionService {
}
/**
* Mark the swap committed; the rollback snapshots are deleted.
* Mark the swap committed; the rollback snapshots and (any) staged
* leftovers are deleted, and the manifest's terminal phase is recorded.
*/
commit(handle: SwapRollbackHandle): void {
if (!handle || handle.rolledBack) return;
if (!handle || handle.rolledBack || handle.committed) return;
for (const r of handle.pairs) {
try {
if (fs.existsSync(r.rollbackPath)) {
@@ -158,24 +265,403 @@ export class FilesystemTransactionService {
if (fs.existsSync(r.stagedPath)) {
fs.rmSync(r.stagedPath, { recursive: true, force: true });
}
// Sidecars: a successful commit means the new live has fresh
// sidecars, but any leftover from the staged tree should be gone.
for (const suffix of r.sidecarSuffixes) {
const stagedSidecar = `${r.stagedPath}${suffix}`;
if (fs.existsSync(stagedSidecar)) {
try { fs.rmSync(stagedSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
} catch (err) {
this.logger.warn(
`Failed to clean rollback artifact ${r.rollbackPath}: ${(err as Error)?.message}`,
);
}
}
if (handle.manifestDir) {
try {
FilesystemTransactionService.rmSafe(handle.manifestDir);
} catch {
// ignore
}
}
handle.rolledBack = true;
handle.committed = true;
}
/**
* Reset the swap state after a successful commit (also clears
* rolledBack flag so commit() is safely idempotent).
* Idempotent terminal step retained for backwards compatibility.
*/
finalize(handle: SwapRollbackHandle): void {
if (!handle) return;
this.commit(handle);
if (!handle.committed && !handle.rolledBack) this.commit(handle);
}
/**
* Scan a system-staging root for unfinished transactions and resolve
* each one to a coherent state. Intended to be called once during
* startup BEFORE the database / uploads are opened.
*
* Returns a summary that the caller can log. Throws if any transaction
* cannot be safely resolved (ambiguous manifest state). Throwing at
* startup is preferable to silently producing a hybrid state.
*/
recoverTransactions(stagingDir: string, opts: { now?: number; maxAgeMs?: number } = {}): RecoveryReport {
FilesystemTransactionService.ensureDir(stagingDir);
const report: RecoveryReport = { transactionsScanned: 0, resolved: 0, aborted: [], cleaned: [] };
const entries = fs.readdirSync(stagingDir, { withFileTypes: true });
const now = opts.now ?? Date.now();
const maxAgeMs = opts.maxAgeMs ?? 7 * 24 * 60 * 60_000;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const txDir = path.join(stagingDir, entry.name);
const manifestPath = path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME);
if (!fs.existsSync(manifestPath)) {
// Orphan tx dir without a manifest — only remove if stale (we never delete anything tied to a live manifest).
try {
const stat = fs.statSync(txDir);
if (now - stat.mtimeMs > maxAgeMs) {
FilesystemTransactionService.rmSafe(txDir);
report.cleaned.push(txDir);
}
} catch {
// ignore
}
continue;
}
report.transactionsScanned += 1;
let manifest: TransactionManifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as TransactionManifest;
} catch (err) {
report.aborted.push(manifestPath);
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_FAILED,
`Unreadable transaction manifest at ${manifestPath}: ${(err as Error).message}`,
500,
{ details: { phase: 'recovery' } },
);
}
// Terminal phases need no further action.
if (manifest.phase === 'committed' || manifest.phase === 'rolled-back') {
FilesystemTransactionService.rmSafe(txDir);
report.resolved += 1;
continue;
}
// Mid-swap recovery decision.
try {
const decision = this.resolveInterruptedManifest(manifest);
if (decision === 'keep-old') {
this.restoreOldGeneration(manifest, txDir);
} else if (decision === 'keep-new') {
this.finalizeNewGeneration(manifest, txDir);
} else {
report.aborted.push(manifestPath);
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_FAILED,
`Cannot resolve interrupted transaction ${manifest.transactionId} at phase=${manifest.phase}: neither old nor new generation is fully present`,
500,
{ details: { phase: 'recovery', transactionId: manifest.transactionId, transactionPhase: manifest.phase } },
);
}
FilesystemTransactionService.rmSafe(txDir);
report.resolved += 1;
} catch (err) {
if ((err as ApiError)?.code) throw err;
report.aborted.push(manifestPath);
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_FAILED,
`Recovery of transaction ${manifest.transactionId} failed: ${(err as Error).message}`,
500,
{ details: { phase: 'recovery', transactionId: manifest.transactionId } },
);
}
}
return report;
}
/**
* Sweep unowned rollback / restore-stage / wipe-stage fragments that
* sit NEXT to a live path (database or uploads) and are not currently
* tracked by any manifest. These can be left behind by an interrupted
* swap from before this durable manifest existed, OR by an interrupted
* wipe-challenges snapshot. Only artifacts older than `maxAgeMs` are
* removed so that a perfectly healthy in-flight operation is never
* disturbed.
*/
sweepUnownedArtifactsNearLivePaths(
paths: string[],
opts: { now?: number; maxAgeMs?: number; stagingDir?: string } = {},
): string[] {
const cleaned: string[] = [];
const now = opts.now ?? Date.now();
const maxAgeMs = opts.maxAgeMs ?? 24 * 60 * 60_000;
const stagingDir = opts.stagingDir;
const candidates = (livePath: string, suffixRegex: RegExp): string[] => {
const dir = path.dirname(livePath);
const base = path.basename(livePath);
if (!fs.existsSync(dir)) return [];
const result: string[] = [];
for (const entry of fs.readdirSync(dir)) {
if (!suffixRegex.test(entry)) continue;
// The artifact must be derived from this specific live path (its
// base name must appear at the start of the artifact).
if (!entry.startsWith(`${base}.`) && !entry.startsWith(`.${base}.`)) continue;
const full = path.join(dir, entry);
try {
const stat = fs.statSync(full);
if (now - stat.mtimeMs < maxAgeMs) continue;
} catch {
continue;
}
// Skip artifacts actively tracked by a manifest.
if (stagingDir && FilesystemTransactionService.hasManifestTracking(stagingDir, full)) continue;
result.push(full);
}
return result;
};
const restoreStageRe = /\.restore-stage-[\w-]+$/;
// Either a hidden (`.<base>.rollback-…`) or visible (`<base>.rollback-…`)
// rollback artifact derived from the live path basename.
const rollbackRe = /^\.?[^/]+\.rollback-[\w-]+$/;
const wipeStageRe = /^\.challenges\.wipe-stage-[\w-]+$/;
for (const livePath of paths) {
const directDir = path.dirname(livePath);
if (!fs.existsSync(directDir)) continue;
// For the uploadDir live, also sweep the `challenges/` subdirectory
// (used by DangerZoneService.wipeChallenges()).
const extraDirs: string[] = [];
if (fs.existsSync(livePath)) {
const stat = fs.statSync(livePath);
if (stat.isDirectory()) {
extraDirs.push(path.join(livePath, 'challenges'));
}
}
const allArtifacts = [
...candidates(livePath, restoreStageRe),
...candidates(livePath, rollbackRe),
...candidates(livePath, wipeStageRe),
];
for (const artifact of allArtifacts) {
FilesystemTransactionService.rmSafe(artifact);
cleaned.push(artifact);
}
for (const extra of extraDirs) {
if (!fs.existsSync(extra)) continue;
for (const entry of fs.readdirSync(extra)) {
if (!wipeStageRe.test(entry)) continue;
const full = path.join(extra, entry);
try {
const stat = fs.statSync(full);
if (now - stat.mtimeMs < maxAgeMs) continue;
} catch {
continue;
}
if (stagingDir && FilesystemTransactionService.hasManifestTracking(stagingDir, full)) continue;
FilesystemTransactionService.rmSafe(full);
cleaned.push(full);
}
}
}
return cleaned;
}
/**
* Decide whether to restore the OLD generation (live+rollback both
* present) or keep the NEW generation (live already swapped + rollback
* present). Returns null when neither generation is complete.
*/
private resolveInterruptedManifest(manifest: TransactionManifest): 'keep-old' | 'keep-new' | null {
const generationPairs = manifest.pairs.filter((p) => p.contributesToGeneration);
if (generationPairs.length === 0) return 'keep-new';
// Old generation complete iff every livePath is currently at the
// rollbackPath (no swap happened for that pair). Once a swap has been
// recorded as `replacements-installed`, the live path is the new
// generation.
if (manifest.phase === 'prepared') {
// Nothing has happened yet — the live tree is still the old one and
// there is no rollback because the rename hasn't run. We treat this
// as a clean "keep-old" with no filesystem action.
return 'keep-old';
}
if (manifest.phase === 'live-snapshotted') {
const oldComplete = generationPairs.every((p) => fs.existsSync(p.rollbackPath) && !fs.existsSync(p.livePath));
return oldComplete ? 'keep-old' : null;
}
if (manifest.phase === 'replacements-installed' || manifest.phase === 'verified' || manifest.phase === 'committed') {
const newComplete = generationPairs.every((p) => fs.existsSync(p.livePath) && fs.existsSync(p.rollbackPath));
return newComplete ? 'keep-new' : null;
}
return null;
}
/**
* Restore the OLD generation: move each rollback path back to the live path
* (or, when the live path already holds the new generation due to an
* earlier partial swap, remove it first). The rollback is the snapshot of
* the pre-swap live tree.
*/
private restoreOldGeneration(manifest: TransactionManifest, txDir: string): void {
if (manifest.phase === 'prepared') {
// Nothing was moved; just record the terminal state.
this.updateManifest(manifest, txDir, 'rolled-back');
return;
}
for (const p of manifest.pairs) {
// Remove any partial new-generation content that may already exist at livePath.
if (fs.existsSync(p.livePath)) {
FilesystemTransactionService.rmSafe(p.livePath);
}
if (fs.existsSync(p.rollbackPath)) {
FilesystemTransactionService.ensureDir(path.dirname(p.livePath));
try {
fs.renameSync(p.rollbackPath, p.livePath);
} catch {
FilesystemTransactionService.copyDirSync(p.rollbackPath, p.livePath);
try { fs.rmSync(p.rollbackPath, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
// Sidecars of the OLD generation are stored next to the rollback path
// (e.g. `rollbackPath-wal`); restore them too if present.
for (const suffix of p.sidecarSuffixes) {
const rollbackSidecar = `${p.rollbackPath}${suffix}`;
const liveSidecar = `${p.livePath}${suffix}`;
if (fs.existsSync(rollbackSidecar)) {
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
FilesystemTransactionService.ensureDir(path.dirname(liveSidecar));
try {
fs.renameSync(rollbackSidecar, liveSidecar);
} catch {
FilesystemTransactionService.copyDirSync(rollbackSidecar, liveSidecar);
try { fs.rmSync(rollbackSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
if (fs.existsSync(liveSidecar) && !fs.existsSync(rollbackSidecar)) {
// No source sidecar and the live sidecar remains — leave it removed.
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
this.updateManifest(manifest, txDir, 'rolled-back');
}
/**
* Finalize the NEW generation: keep the live replacement in place,
* remove the rollback snapshot and any stale sidecars.
*/
private finalizeNewGeneration(manifest: TransactionManifest, txDir: string): void {
for (const p of manifest.pairs) {
// Drop the rollback copy; the new live is now durable.
FilesystemTransactionService.rmSafe(p.rollbackPath);
// Remove any unused staged remnants and stale sidecars.
FilesystemTransactionService.rmSafe(p.stagedPath);
for (const suffix of p.sidecarSuffixes) {
const stagedSidecar = `${p.stagedPath}${suffix}`;
FilesystemTransactionService.rmSafe(stagedSidecar);
// Keep the live sidecar if it was written by the new replacement;
// it should be empty (just created) so nothing further to clean.
}
}
this.updateManifest(manifest, txDir, 'committed');
}
/**
* Helper that callers (e.g. DatabaseInitService) can use to decide
* whether a given rollback/staged artifact is already tracked by a
* live manifest in the staging root.
*/
static hasManifestTracking(stagingDir: string, rollbackPath: string): boolean {
if (!fs.existsSync(stagingDir)) return false;
const entries = fs.readdirSync(stagingDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(stagingDir, entry.name, FilesystemTransactionService.MANIFEST_FILENAME);
if (!fs.existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as TransactionManifest;
for (const p of manifest.pairs) {
if (p.rollbackPath === rollbackPath || p.stagedPath === rollbackPath || p.livePath === rollbackPath) {
return true;
}
}
} catch {
// ignore unreadable manifests; recovery will surface them
}
}
return false;
}
/* ───────── Manifest helpers ───────── */
private ensureTxDir(stagingDir: string): string {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const full = path.join(stagingDir, id);
FilesystemTransactionService.ensureDir(full);
return full;
}
private makeInitialManifest(input: StageSwapInput, txDir: string): TransactionManifest {
const transactionId = path.basename(txDir);
const now = new Date().toISOString();
const manifest: TransactionManifest = {
schemaVersion: 1,
transactionId,
operation: input.name,
createdAt: now,
updatedAt: now,
phase: 'prepared',
pairs: [],
};
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
for (const pair of input.pairs) {
const rollbackPath = FilesystemTransactionService.makeRollbackPath(pair.livePath, stamp);
manifest.pairs.push({
livePath: pair.livePath,
rollbackPath,
stagedPath: pair.stagedPath,
contributesToGeneration: pair.contributesToGeneration ?? true,
sidecarSuffixes: pair.sidecarSuffixes ?? [],
});
}
this.writeManifestAtomic(manifest, txDir);
return manifest;
}
private updateManifest(manifest: TransactionManifest, txDir: string, phase: TransactionPhase): void {
manifest.phase = phase;
manifest.updatedAt = new Date().toISOString();
this.writeManifestAtomic(manifest, txDir);
}
private writeManifestAtomic(manifest: TransactionManifest, txDir: string): void {
const target = path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME);
const tmp = `${target}.tmp-${Math.random().toString(36).slice(2, 8)}`;
FilesystemTransactionService.ensureDir(path.dirname(tmp));
fs.writeFileSync(tmp, JSON.stringify(manifest));
fs.renameSync(tmp, target);
}
private manifestPathFor(_transactionId: string): string {
// Used only by recovery paths that already know where the manifest lives.
void _transactionId;
return FilesystemTransactionService.MANIFEST_FILENAME;
}
/* ───────── Static filesystem helpers ───────── */
/**
* Recursively remove a directory or file. Best-effort.
*/
@@ -188,8 +674,7 @@ export class FilesystemTransactionService {
}
/**
* Copy a directory recursively, falling back gracefully if the source
* doesn't exist (returns immediately).
* Copy a directory recursively; gracefully no-ops if the source doesn't exist.
*/
static copyDirSync(src: string, dest: string): void {
if (!fs.existsSync(src)) return;
@@ -211,7 +696,7 @@ export class FilesystemTransactionService {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
private static makeRollbackPath(livePath: string, stamp: string): string {
static makeRollbackPath(livePath: string, stamp: string): string {
const dir = path.dirname(livePath);
const base = path.basename(livePath);
return path.join(dir, `.${base}.rollback-${stamp}`);
@@ -221,11 +706,9 @@ export class FilesystemTransactionService {
if (!handle || handle.rolledBack) return;
// Walk pairs in reverse order to maximize recovery chances.
for (const r of [...handle.pairs].reverse()) {
const stageStillAtLive = fs.existsSync(r.livePath) && r.stagedPath
const stageStillAtLive = fs.existsSync(r.livePath)
? (() => {
try {
const liveStat = fs.statSync(r.livePath);
// If we already swapped, stagedDir is gone.
return !fs.existsSync(r.stagedPath);
} catch {
return true;
@@ -235,11 +718,7 @@ export class FilesystemTransactionService {
try {
if (stageStillAtLive) {
// swap happened — remove the now-swapped content before restoring.
try {
fs.rmSync(r.livePath, { recursive: true, force: true });
} catch {
// ignore
}
FilesystemTransactionService.rmSafe(r.livePath);
}
if (fs.existsSync(r.rollbackPath)) {
FilesystemTransactionService.ensureDir(path.dirname(r.livePath));
@@ -247,19 +726,25 @@ export class FilesystemTransactionService {
fs.renameSync(r.rollbackPath, r.livePath);
} catch {
FilesystemTransactionService.copyDirSync(r.rollbackPath, r.livePath);
try {
fs.rmSync(r.rollbackPath, { recursive: true, force: true });
} catch {
// ignore
}
try { fs.rmSync(r.rollbackPath, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
// If a staged dir remained because swap failed before move, clean it up.
if (fs.existsSync(r.stagedPath)) {
try {
fs.rmSync(r.stagedPath, { recursive: true, force: true });
} catch {
// ignore
FilesystemTransactionService.rmSafe(r.stagedPath);
}
// Restore sidecars if they were tracked.
for (const suffix of r.sidecarSuffixes) {
const liveSidecar = `${r.livePath}${suffix}`;
const rollbackSidecar = `${r.rollbackPath}${suffix}`;
if (fs.existsSync(rollbackSidecar)) {
FilesystemTransactionService.rmSafe(liveSidecar);
FilesystemTransactionService.ensureDir(path.dirname(liveSidecar));
try {
fs.renameSync(rollbackSidecar, liveSidecar);
} catch {
FilesystemTransactionService.copyDirSync(rollbackSidecar, liveSidecar);
try { fs.rmSync(rollbackSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
} catch (err) {
@@ -65,6 +65,7 @@ export class RestoreService {
private readonly logger = new Logger(RestoreService.name);
private readonly uploadDir: string;
private readonly databasePath: string;
private readonly systemStagingDir: string;
private readonly stageTtlMs: number;
private readonly restoreUploadLimit: number;
@@ -78,6 +79,7 @@ export class RestoreService {
) {
this.uploadDir = path.resolve(this.configService.get<string>('UPLOAD_DIR', './data/uploads'));
this.databasePath = path.resolve(this.configService.get<string>('DATABASE_PATH', './data/db.sqlite'));
this.systemStagingDir = resolveSystemStagingDir(this.configService);
this.stageTtlMs = this.configService.get<number>('SYSTEM_OP_RESTORE_STAGE_TTL_MS', 15 * 60_000);
this.restoreUploadLimit = parseUploadSizeLimit(this.configService.get<string>('BODY_SIZE_LIMIT', '50mb'));
}
@@ -230,8 +232,6 @@ export class RestoreService {
throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED, 'Restore stage has expired; please validate again', 400);
}
const stagingDbPath = path.join(staged.stagingRoot, 'rebuild.sqlite');
const liveBackupDb = `${this.databasePath}.rollback-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
void liveBackupDb;
const stagedUploadsReplace = `${this.uploadDir}.restore-stage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let handle: SwapRollbackHandle | null = null;
@@ -246,18 +246,30 @@ export class RestoreService {
FilesystemTransactionService.ensureDir(this.uploadDir);
FilesystemTransactionService.copyDirSync(staged.uploadsRoot, stagedUploadsReplace);
handle = await this.fsTx.stageSwap({
name: 'restore-backup',
pairs: [
{ livePath: this.databasePath, stagedPath: stagingDbPath },
{ livePath: this.uploadDir, stagedPath: stagedUploadsReplace },
],
hooks: {
afterSwap: async () => {
await this.verifySwappedDatabase();
handle = await this.fsTx.stageSwap(
{
name: 'restore-backup',
pairs: [
{
livePath: this.databasePath,
stagedPath: stagingDbPath,
contributesToGeneration: true,
sidecarSuffixes: ['-wal', '-shm'],
},
{
livePath: this.uploadDir,
stagedPath: stagedUploadsReplace,
contributesToGeneration: true,
},
],
hooks: {
afterSwap: async () => {
await this.verifySwappedDatabase();
},
},
},
});
{ stagingDir: this.systemStagingDir },
);
// Re-establish the live DataSource against the new database. The
// global TypeORM connection will pick up the swapped file on next