@syngrisi/syngrisi
Version:
Syngrisi - Visual Testing Tool
947 lines (831 loc) • 32.1 kB
text/typescript
import fs from 'fs';
import { promises as fsp } from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { createGzip, createGunzip } from 'zlib';
import { promisify } from 'util';
import { pipeline, Readable } from 'stream';
import tar from 'tar-stream';
import mongoose from 'mongoose';
import { UploadedFile } from 'express-fileupload';
import { config } from '@config';
const pipelineAsync = promisify(pipeline);
const { BSON } = mongoose.mongo;
export type AdminDataJobType =
| 'db_backup'
| 'db_restore'
| 'screenshots_backup'
| 'screenshots_restore';
export type AdminDataJobStatus =
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'cancelled';
export interface AdminDataJobProgress {
stage: string;
current?: number;
total?: number;
percent?: number;
}
export interface AdminDataJobStats {
archiveSizeBytes?: number;
processedFiles?: number;
totalFiles?: number;
importedFiles?: number;
skippedFiles?: number;
errorFiles?: number;
}
export interface AdminDataJob {
id: string;
type: AdminDataJobType;
status: AdminDataJobStatus;
params: Record<string, unknown>;
progress: AdminDataJobProgress;
message: string;
stats: AdminDataJobStats;
downloadAvailable: boolean;
archiveName?: string;
archivePath?: string;
uploadPath?: string;
workDir: string;
logFilePath: string;
createdAt: string;
startedAt?: string;
finishedAt?: string;
error?: string;
}
type ActiveTaskState = {
cancelRequested: boolean;
};
type DbBackupManifest = {
format: 'syngrisi-db-backup-v1';
exportedAt: string;
databaseName: string;
collections: Array<{
name: string;
dumpFile: string;
documentCount: number;
indexes: Array<Record<string, unknown>>;
}>;
};
const activeTasks = new Map<string, ActiveTaskState>();
const META_FILENAME = 'job.json';
const LOG_FILENAME = 'job.log';
const DB_EXPORT_DIRNAME = 'db-export';
const isActiveStatus = (status: AdminDataJobStatus) => status === 'pending' || status === 'running';
const getJobDir = (jobId: string) => path.join(config.adminDataJobsPath, jobId);
const getJobMetaPath = (jobId: string) => path.join(getJobDir(jobId), META_FILENAME);
const getJobLogPath = (jobId: string) => path.join(getJobDir(jobId), LOG_FILENAME);
async function ensureDir(dirPath: string) {
await fsp.mkdir(dirPath, { recursive: true });
}
async function removeDirSafe(dirPath?: string) {
if (!dirPath) return;
await fsp.rm(dirPath, { recursive: true, force: true });
}
async function fileExists(filePath: string) {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
async function writeJob(job: AdminDataJob) {
await ensureDir(job.workDir);
await fsp.writeFile(getJobMetaPath(job.id), JSON.stringify(job, null, 2));
}
async function appendLog(jobId: string, message: string) {
const line = `[${new Date().toISOString()}] ${message}\n`;
await fsp.appendFile(getJobLogPath(jobId), line);
}
async function readJob(jobId: string): Promise<AdminDataJob | null> {
try {
const raw = await fsp.readFile(getJobMetaPath(jobId), 'utf8');
return JSON.parse(raw) as AdminDataJob;
} catch {
return null;
}
}
async function listJobsInternal(): Promise<AdminDataJob[]> {
await ensureDir(config.adminDataJobsPath);
const entries = await fsp.readdir(config.adminDataJobsPath, { withFileTypes: true });
const jobs = await Promise.all(entries.filter((entry) => entry.isDirectory()).map((entry) => readJob(entry.name)));
return jobs
.filter((job): job is AdminDataJob => Boolean(job))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
async function updateJob(jobId: string, patch: Partial<AdminDataJob>) {
const current = await readJob(jobId);
if (!current) {
throw new Error(`Job not found: ${jobId}`);
}
const nextJob = { ...current, ...patch };
await writeJob(nextJob);
return nextJob;
}
async function updateProgress(jobId: string, progress: AdminDataJobProgress, message?: string, statsPatch?: Partial<AdminDataJobStats>) {
const current = await readJob(jobId);
if (!current) return;
const nextProgress = { ...current.progress, ...progress };
if (typeof nextProgress.current === 'number' && typeof nextProgress.total === 'number' && nextProgress.total > 0) {
nextProgress.percent = Math.min(100, Math.round((nextProgress.current / nextProgress.total) * 100));
}
await writeJob({
...current,
progress: nextProgress,
message: message ?? current.message,
stats: { ...current.stats, ...statsPatch },
});
}
async function finalizeJob(jobId: string, status: AdminDataJobStatus, patch: Partial<AdminDataJob> = {}) {
const current = await readJob(jobId);
if (!current) return null;
const job = {
...current,
...patch,
status,
finishedAt: new Date().toISOString(),
};
await writeJob(job);
return job;
}
function normalizeArchiveName(type: AdminDataJobType) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
switch (type) {
case 'db_backup':
return `syngrisi-db-backup-${stamp}.tar.gz`;
case 'screenshots_backup':
return `syngrisi-screenshots-backup-${stamp}.tar.gz`;
default:
return `${type}-${stamp}.tar.gz`;
}
}
async function getRunningJobs() {
const jobs = await listJobsInternal();
return jobs.filter((job) => isActiveStatus(job.status));
}
async function hasActiveDatabaseRestoreJob() {
const jobs = await getRunningJobs();
return jobs.some((job) => job.type === 'db_restore');
}
async function assertCanStartJob() {
const runningJobs = await getRunningJobs();
if (runningJobs.length >= config.adminDataMaxConcurrentJobs) {
throw new Error(`Another data job is already active: ${runningJobs[0].id}`);
}
}
async function countFilesRecursive(rootDir: string): Promise<number> {
let count = 0;
const stack = [rootDir];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) continue;
const dir = await fsp.opendir(currentDir);
for await (const entry of dir) {
const entryPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
} else if (entry.isFile()) {
count += 1;
}
}
}
return count;
}
async function streamToFile(sourcePath: string, targetPath: string) {
await ensureDir(path.dirname(targetPath));
await pipelineAsync(fs.createReadStream(sourcePath), fs.createWriteStream(targetPath));
}
function getActiveState(jobId: string) {
return activeTasks.get(jobId);
}
function assertNotCancelled(jobId: string) {
if (getActiveState(jobId)?.cancelRequested) {
throw new Error('Job cancelled');
}
}
async function createJob(type: AdminDataJobType, params: Record<string, unknown>) {
await assertCanStartJob();
const id = randomUUID();
const workDir = getJobDir(id);
await ensureDir(workDir);
const job: AdminDataJob = {
id,
type,
status: 'pending',
params,
progress: { stage: 'queued', percent: 0 },
message: 'Queued',
stats: {},
downloadAvailable: false,
workDir,
logFilePath: getJobLogPath(id),
createdAt: new Date().toISOString(),
};
await writeJob(job);
await appendLog(id, `Job created: ${type}`);
return job;
}
async function addFileToTar(pack: tar.Pack, filePath: string, entryName: string) {
const stat = await fsp.stat(filePath);
await new Promise<void>((resolve, reject) => {
const entry = pack.entry({ name: entryName, size: stat.size, mode: stat.mode }, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
fs.createReadStream(filePath).on('error', reject).pipe(entry).on('error', reject);
});
}
async function createTarGzArchive(outputPath: string, items: Array<{ path: string; name: string }>) {
await ensureDir(path.dirname(outputPath));
const pack = tar.pack();
const gzip = createGzip();
const output = fs.createWriteStream(outputPath);
const pipelinePromise = pipelineAsync(pack, gzip, output);
for (const item of items) {
await addFileToTar(pack, item.path, item.name);
}
pack.finalize();
await pipelinePromise;
}
async function extractTarGzArchive(archivePath: string, destinationDir: string) {
await ensureDir(destinationDir);
const extract = tar.extract();
await new Promise<void>((resolve, reject) => {
extract.on('entry', (header, stream, next) => {
const outputPath = path.join(destinationDir, header.name);
const finishEntry = (error?: Error | null) => {
if (error) {
reject(error);
return;
}
next();
};
if (header.type === 'directory') {
void ensureDir(outputPath).then(() => {
stream.resume();
finishEntry();
}).catch((error) => finishEntry(error as Error));
return;
}
void ensureDir(path.dirname(outputPath))
.then(() => pipelineAsync(stream, fs.createWriteStream(outputPath)))
.then(() => finishEntry())
.catch((error) => finishEntry(error as Error));
});
extract.on('finish', () => resolve());
extract.on('error', reject);
fs.createReadStream(archivePath)
.on('error', reject)
.pipe(createGunzip())
.on('error', reject)
.pipe(extract)
.on('error', reject);
});
}
async function countFilesInTarGzArchive(archivePath: string) {
const extract = tar.extract();
let totalFiles = 0;
await new Promise<void>((resolve, reject) => {
extract.on('entry', (_header, stream, next) => {
if (_header.type === 'file') {
totalFiles += 1;
}
stream.resume();
next();
});
extract.on('finish', () => resolve());
extract.on('error', reject);
fs.createReadStream(archivePath)
.on('error', reject)
.pipe(createGunzip())
.on('error', reject)
.pipe(extract)
.on('error', reject);
});
return totalFiles;
}
async function writeCollectionDump(jobId: string, collectionName: string, outputPath: string) {
const db = mongoose.connection.db;
if (!db) {
throw new Error('MongoDB connection is not available');
}
let documentCount = 0;
await ensureDir(path.dirname(outputPath));
const gzip = createGzip();
const output = fs.createWriteStream(outputPath);
gzip.pipe(output);
const cursor = db.collection(collectionName).find({}, { timeout: false });
for await (const doc of cursor) {
assertNotCancelled(jobId);
gzip.write(BSON.serialize(doc));
documentCount += 1;
if (documentCount % 1000 === 0) {
await appendLog(jobId, `Exported ${documentCount} documents from ${collectionName}`);
}
}
gzip.end();
await new Promise<void>((resolve, reject) => {
output.on('finish', () => resolve());
output.on('error', reject);
gzip.on('error', reject);
});
return documentCount;
}
async function walkFiles(rootDir: string, onFile: (fullPath: string, relativePath: string) => Promise<void>) {
const stack = [rootDir];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) continue;
const dir = await fsp.opendir(currentDir);
for await (const entry of dir) {
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(rootDir, fullPath);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
await onFile(fullPath, relativePath);
}
}
}
}
async function runDbBackup(job: AdminDataJob) {
const db = mongoose.connection.db;
if (!db) {
throw new Error('MongoDB connection is not available');
}
const archiveName = normalizeArchiveName(job.type);
const archivePath = path.join(job.workDir, archiveName);
const exportDir = path.join(job.workDir, DB_EXPORT_DIRNAME);
await ensureDir(exportDir);
await updateProgress(job.id, { stage: 'indexing', percent: 5 }, 'Inspecting database');
const collectionInfos = await db.listCollections({}, { nameOnly: true }).toArray();
const collections = collectionInfos.map((item) => item.name).filter((name) => !name.startsWith('system.'));
const manifest: DbBackupManifest = {
format: 'syngrisi-db-backup-v1',
exportedAt: new Date().toISOString(),
databaseName: db.databaseName,
collections: [],
};
let processedCollections = 0;
for (const collectionName of collections) {
assertNotCancelled(job.id);
const dumpFileName = `${collectionName}.bson.gz`;
const dumpPath = path.join(exportDir, dumpFileName);
await updateProgress(job.id, { stage: 'dumping', current: processedCollections, total: collections.length }, `Exporting ${collectionName}`);
const documentCount = await writeCollectionDump(job.id, collectionName, dumpPath);
const indexes = await db.collection(collectionName).indexes();
manifest.collections.push({
name: collectionName,
dumpFile: dumpFileName,
documentCount,
indexes: indexes.map((index) => JSON.parse(JSON.stringify(index))),
});
processedCollections += 1;
await updateProgress(job.id, { stage: 'dumping', current: processedCollections, total: collections.length }, `Exported ${collectionName}`);
}
const manifestPath = path.join(exportDir, 'manifest.json');
await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
await updateProgress(job.id, { stage: 'archiving', percent: 80 }, 'Creating archive');
const tarItems = [
{ path: manifestPath, name: 'manifest.json' },
...manifest.collections.map((collection) => ({
path: path.join(exportDir, collection.dumpFile),
name: `collections/${collection.dumpFile}`,
})),
];
await createTarGzArchive(archivePath, tarItems);
const stat = await fsp.stat(archivePath);
await finalizeJob(job.id, 'completed', {
message: 'Database backup completed',
archivePath,
archiveName,
downloadAvailable: true,
stats: {
archiveSizeBytes: stat.size,
processedFiles: manifest.collections.reduce((acc, item) => acc + item.documentCount, 0),
totalFiles: manifest.collections.length,
},
progress: { stage: 'completed', percent: 100, current: manifest.collections.length, total: manifest.collections.length },
});
}
async function recreateIndexes(collectionName: string, indexes: Array<Record<string, unknown>>) {
const db = mongoose.connection.db;
if (!db) {
throw new Error('MongoDB connection is not available');
}
const filtered = indexes.filter((index) => index.name !== '_id_');
if (filtered.length === 0) {
return;
}
const definitions = filtered.map((index) => {
const { key, ...options } = index as { key: Record<string, 1 | -1>; [key: string]: unknown };
return { key, ...options };
});
await db.collection(collectionName).createIndexes(definitions as any);
}
async function importCollectionDump(jobId: string, collectionName: string, dumpPath: string) {
const db = mongoose.connection.db;
if (!db) {
throw new Error('MongoDB connection is not available');
}
const collection = db.collection(collectionName);
const batch: Record<string, unknown>[] = [];
let inserted = 0;
const flush = async () => {
if (batch.length === 0) return;
await collection.insertMany(batch, { ordered: false });
inserted += batch.length;
batch.length = 0;
};
const input = fs.createReadStream(dumpPath).pipe(createGunzip());
let pending = Buffer.alloc(0);
for await (const chunk of input) {
assertNotCancelled(jobId);
pending = Buffer.concat([pending, chunk as Buffer]);
while (pending.length >= 4) {
const documentSize = pending.readInt32LE(0);
if (documentSize <= 0) {
throw new Error(`Invalid BSON document size ${documentSize} in ${collectionName}`);
}
if (pending.length < documentSize) {
break;
}
const documentBuffer = pending.subarray(0, documentSize);
pending = pending.subarray(documentSize);
batch.push(BSON.deserialize(documentBuffer) as Record<string, unknown>);
if (batch.length >= 1000) {
await flush();
await appendLog(jobId, `Imported ${inserted} documents into ${collectionName}`);
}
}
}
if (pending.length > 0) {
throw new Error(`Unexpected trailing BSON bytes in ${collectionName}`);
}
await flush();
return inserted;
}
async function runDbRestore(job: AdminDataJob) {
const db = mongoose.connection.db;
if (!db) {
throw new Error('MongoDB connection is not available');
}
const uploadPath = String(job.uploadPath || '');
if (!uploadPath || !(await fileExists(uploadPath))) {
throw new Error('Uploaded archive is missing');
}
const extractDir = path.join(job.workDir, 'extracted-db');
await updateProgress(job.id, { stage: 'extracting', percent: 10 }, 'Extracting database archive');
await extractTarGzArchive(uploadPath, extractDir);
const manifestPath = path.join(extractDir, 'manifest.json');
if (!(await fileExists(manifestPath))) {
throw new Error('manifest.json is missing from database archive');
}
const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')) as DbBackupManifest;
if (manifest.format !== 'syngrisi-db-backup-v1') {
throw new Error('Unsupported database backup format');
}
await updateProgress(job.id, { stage: 'dropping_db', percent: 30 }, 'Dropping current database');
await db.dropDatabase();
let importedCollections = 0;
for (const collectionInfo of manifest.collections) {
assertNotCancelled(job.id);
const dumpPath = path.join(extractDir, 'collections', collectionInfo.dumpFile);
await updateProgress(
job.id,
{ stage: 'restoring', current: importedCollections, total: manifest.collections.length },
`Restoring ${collectionInfo.name}`,
);
await importCollectionDump(job.id, collectionInfo.name, dumpPath);
await recreateIndexes(collectionInfo.name, collectionInfo.indexes);
importedCollections += 1;
await updateProgress(
job.id,
{ stage: 'restoring', current: importedCollections, total: manifest.collections.length },
`Restored ${collectionInfo.name}`,
);
}
await finalizeJob(job.id, 'completed', {
message: 'Database restore completed',
progress: { stage: 'completed', percent: 100, current: importedCollections, total: manifest.collections.length },
stats: {
processedFiles: manifest.collections.reduce((acc, item) => acc + item.documentCount, 0),
totalFiles: manifest.collections.length,
},
});
}
async function runScreenshotsBackup(job: AdminDataJob) {
const archiveName = normalizeArchiveName(job.type);
const archivePath = path.join(job.workDir, archiveName);
const totalFiles = await countFilesRecursive(config.defaultImagesPath);
let processedFiles = 0;
await ensureDir(path.dirname(archivePath));
const pack = tar.pack();
const gzip = createGzip();
const output = fs.createWriteStream(archivePath);
const archivePipeline = pipelineAsync(pack, gzip, output);
await updateProgress(job.id, { stage: 'archiving', current: 0, total: totalFiles }, 'Archiving screenshots', { totalFiles });
await walkFiles(config.defaultImagesPath, async (fullPath, relativePath) => {
assertNotCancelled(job.id);
await addFileToTar(pack, fullPath, relativePath);
processedFiles += 1;
if (processedFiles % 200 === 0 || processedFiles === totalFiles) {
await updateProgress(job.id, { stage: 'archiving', current: processedFiles, total: totalFiles }, 'Archiving screenshots', { processedFiles, totalFiles });
}
});
pack.finalize();
await archivePipeline;
const stat = await fsp.stat(archivePath);
await finalizeJob(job.id, 'completed', {
message: 'Screenshots backup completed',
archivePath,
archiveName,
downloadAvailable: true,
stats: { archiveSizeBytes: stat.size, processedFiles, totalFiles },
progress: { stage: 'completed', percent: 100, current: processedFiles, total: totalFiles },
});
}
async function runScreenshotsRestore(job: AdminDataJob) {
const uploadPath = String(job.uploadPath || '');
const skipExisting = Boolean(job.params.skipExisting);
if (!uploadPath || !(await fileExists(uploadPath))) {
throw new Error('Uploaded archive is missing');
}
const totalFiles = await countFilesInTarGzArchive(uploadPath);
let processedFiles = 0;
let importedFiles = 0;
let skippedFiles = 0;
let errorFiles = 0;
await updateProgress(job.id, { stage: 'importing', current: 0, total: totalFiles }, 'Importing screenshots', { totalFiles });
const extract = tar.extract();
await new Promise<void>((resolve, reject) => {
extract.on('entry', (header, stream, next) => {
const finish = (error?: Error | null) => {
if (error) {
reject(error);
return;
}
next();
};
if (header.type !== 'file') {
stream.resume();
finish();
return;
}
const relativePath = header.name;
const targetPath = path.join(config.defaultImagesPath, relativePath);
void (async () => {
assertNotCancelled(job.id);
const exists = await fileExists(targetPath);
if (exists && skipExisting) {
skippedFiles += 1;
stream.resume();
} else {
await ensureDir(path.dirname(targetPath));
if (exists) {
await fsp.rm(targetPath, { force: true });
}
await pipelineAsync(stream, fs.createWriteStream(targetPath));
importedFiles += 1;
}
processedFiles += 1;
if (processedFiles % 200 === 0 || processedFiles === totalFiles) {
await updateProgress(
job.id,
{ stage: 'importing', current: processedFiles, total: totalFiles },
'Importing screenshots',
{ processedFiles, importedFiles, skippedFiles, errorFiles, totalFiles },
);
}
})().then(() => finish()).catch(async (error: Error) => {
errorFiles += 1;
await appendLog(job.id, `Failed to import ${relativePath}: ${error.message}`);
stream.resume();
processedFiles += 1;
finish();
});
});
extract.on('finish', () => resolve());
extract.on('error', reject);
fs.createReadStream(uploadPath)
.on('error', reject)
.pipe(createGunzip())
.on('error', reject)
.pipe(extract)
.on('error', reject);
});
await finalizeJob(job.id, 'completed', {
message: 'Screenshots restore completed',
progress: { stage: 'completed', percent: 100, current: processedFiles, total: totalFiles },
stats: { totalFiles, processedFiles, importedFiles, skippedFiles, errorFiles },
});
}
async function cleanupExpiredJobs() {
const jobs = await listJobsInternal();
const now = Date.now();
await Promise.all(jobs.map(async (job) => {
if (isActiveStatus(job.status)) return;
const finishedAt = job.finishedAt ? new Date(job.finishedAt).getTime() : new Date(job.createdAt).getTime();
if ((now - finishedAt) > config.adminDataJobsTtlMs) {
await removeDirSafe(job.workDir);
}
}));
}
async function cleanupJobWorkdirs(jobId: string) {
const job = await readJob(jobId);
if (!job) return;
await removeDirSafe(path.join(job.workDir, 'staging'));
await removeDirSafe(path.join(job.workDir, 'extracted'));
await removeDirSafe(path.join(job.workDir, 'extracted-db'));
await removeDirSafe(path.join(job.workDir, DB_EXPORT_DIRNAME));
if (job.status === 'completed' && job.downloadAvailable) {
if (job.uploadPath) {
await fsp.rm(job.uploadPath, { force: true });
}
return;
}
if (job.uploadPath) {
await fsp.rm(job.uploadPath, { force: true });
}
}
async function markStaleJobsFailed() {
const jobs = await listJobsInternal();
await Promise.all(jobs.map(async (job) => {
if (isActiveStatus(job.status)) {
await finalizeJob(job.id, 'failed', {
message: 'Marked as failed after server restart',
error: 'Server restarted while job was running',
downloadAvailable: false,
});
}
}));
}
async function initialize() {
await ensureDir(config.adminDataJobsPath);
await markStaleJobsFailed();
await cleanupExpiredJobs();
}
async function startJob(job: AdminDataJob) {
setImmediate(() => {
void runJob(job.id);
});
}
async function runJob(jobId: string) {
const currentJob = await readJob(jobId);
if (!currentJob || currentJob.status === 'cancelled') {
return;
}
activeTasks.set(jobId, { cancelRequested: false });
const job = await updateJob(jobId, {
status: 'running',
startedAt: new Date().toISOString(),
progress: { stage: 'preparing', percent: 0 },
message: 'Preparing',
});
try {
switch (job.type) {
case 'db_backup':
await runDbBackup(job);
break;
case 'db_restore':
await runDbRestore(job);
break;
case 'screenshots_backup':
await runScreenshotsBackup(job);
break;
case 'screenshots_restore':
await runScreenshotsRestore(job);
break;
default:
throw new Error(`Unsupported job type: ${job.type}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const cancelled = message === 'Job cancelled';
await appendLog(jobId, `Job ${cancelled ? 'cancelled' : 'failed'}: ${message}`);
await finalizeJob(jobId, cancelled ? 'cancelled' : 'failed', {
error: cancelled ? undefined : message,
message: cancelled ? 'Cancelled' : message,
downloadAvailable: false,
});
} finally {
activeTasks.delete(jobId);
await cleanupJobWorkdirs(jobId);
}
}
async function persistUploadToJobDir(file: UploadedFile, jobDir: string, fileName: string) {
const targetPath = path.join(jobDir, fileName);
await ensureDir(jobDir);
if (file.tempFilePath) {
await fsp.rename(file.tempFilePath, targetPath).catch(async () => {
await streamToFile(file.tempFilePath, targetPath);
await fsp.rm(file.tempFilePath, { force: true });
});
} else {
await fsp.writeFile(targetPath, file.data);
}
return targetPath;
}
async function createDbBackupJob() {
const job = await createJob('db_backup', {});
await startJob(job);
return readJob(job.id);
}
async function createScreenshotsBackupJob() {
const job = await createJob('screenshots_backup', {});
await startJob(job);
return readJob(job.id);
}
async function createDbRestoreJob(file: UploadedFile) {
const job = await createJob('db_restore', {});
const uploadPath = await persistUploadToJobDir(file, path.join(job.workDir, 'staging'), file.name || 'db-restore.tar.gz');
await updateJob(job.id, {
uploadPath,
message: 'Upload stored, waiting for restore',
});
const updated = await readJob(job.id);
if (!updated) throw new Error('Failed to read created job');
await startJob(updated);
return readJob(updated.id);
}
async function createScreenshotsRestoreJob(file: UploadedFile, skipExisting: boolean) {
const job = await createJob('screenshots_restore', { skipExisting });
const uploadPath = await persistUploadToJobDir(file, path.join(job.workDir, 'staging'), file.name || 'screenshots-restore.tar.gz');
await updateJob(job.id, {
uploadPath,
message: 'Upload stored, waiting for restore',
});
const updated = await readJob(job.id);
if (!updated) throw new Error('Failed to read created job');
await startJob(updated);
return readJob(updated.id);
}
async function getJob(jobId: string) {
return readJob(jobId);
}
async function getJobLog(jobId: string) {
try {
return await fsp.readFile(getJobLogPath(jobId), 'utf8');
} catch {
return '';
}
}
async function cancelJob(jobId: string) {
const job = await readJob(jobId);
if (!job) {
throw new Error(`Job not found: ${jobId}`);
}
const active = activeTasks.get(jobId);
if (!active) {
return finalizeJob(jobId, job.status === 'pending' ? 'cancelled' : job.status, {
message: job.status === 'pending' ? 'Cancelled' : job.message,
});
}
active.cancelRequested = true;
await appendLog(jobId, 'Cancellation requested');
return updateJob(jobId, { message: 'Cancellation requested' });
}
async function createDownloadStream(jobId: string) {
const job = await readJob(jobId);
if (!job || job.status !== 'completed' || !job.downloadAvailable || !job.archivePath) {
throw new Error('Download is not available for this job');
}
if (!(await fileExists(job.archivePath))) {
throw new Error('Archive file is missing');
}
return {
fileName: job.archiveName || path.basename(job.archivePath),
stream: fs.createReadStream(job.archivePath),
};
}
async function deleteJob(jobId: string) {
const job = await readJob(jobId);
if (!job) {
throw new Error(`Job not found: ${jobId}`);
}
if (isActiveStatus(job.status)) {
throw new Error('Cannot delete an active job. Cancel it first.');
}
await removeDirSafe(job.workDir);
return { deleted: true, id: jobId };
}
export const adminDataJobService = {
initialize,
listJobs: listJobsInternal,
getJob,
getJobLog,
createDbBackupJob,
createDbRestoreJob,
createScreenshotsBackupJob,
createScreenshotsRestoreJob,
cancelJob,
deleteJob,
createDownloadStream,
hasActiveDatabaseRestoreJob,
};