UNPKG

@syngrisi/syngrisi

Version:
947 lines (831 loc) 32.1 kB
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, };