feat: 完善媒体管理与备份迁移流程

- 视频上传生成缩略图并提供占位封面和失败原因展示
- 支持 HLS 按需重新封装 MP4,并限制临时文件生命周期与访问权限
- 完善视频、文件 API 文档、搜索、批量删除和单次转码质量参数
- 修复备份恢复与跨后端迁移遗漏缩略图、HLS 清单和分片的问题
- 将备份任务、归档和下载权限按用户隔离
- 增加仅清理当前用户数据的高危存储清空流程和二次文字确认
- 修复 S3 中文文件名元数据及 CLI 数据库连接问题
- 补充备份恢复、用户隔离、媒体资源和转码相关测试
This commit is contained in:
tianmo committed 2026-10-17 13:46:02 +08:00
1 parent 9ebab8270c
commit a64be50b73
60 files changed
+2703 -325

No files matched your search

+51 -40
View File
@@ -1,18 +1,22 @@
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import {
createBackendInstance,
getStorageBackendRow,
setDefaultBackend
} from '../storage-backends'
import {
getImageIndexAsStored,
getImageIndexRow,
listImageIndexRowsByBackend,
updateImageBackendId
} from '../image-index'
import { createImageStream } from '../storage'
import { contentTypeFromKey } from '../content-type'
import { mediaTypeForKey } from '../image-key'
import type { ImageIndexRow } from '../image-index'
import type { StorageBackend } from '../storage/types'
import { getBackupJob, markJobDone, markJobFailed, updateBackupJob } from './jobs'
import { getBackupStagingDir } from './paths'
import { collectMediaAssetKeys } from '../media-assets'
export interface BackendSyncOptions {
fromBackendId: string
@@ -63,6 +67,44 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return Buffer.concat(chunks)
}
async function syncMediaRow(
row: ImageIndexRow,
fromBackend: StorageBackend,
toBackend: StorageBackend,
deleteSource: boolean
): Promise<void> {
const keys = await collectMediaAssetKeys(row, fromBackend)
if (!keys.length) throw new Error('没有可同步的文件对象')
for (const key of keys) {
const stored = await fromBackend.head(key)
if (!stored) throw new Error(`源文件不存在:${key}`)
const bytes = await streamToBuffer(await fromBackend.createStream(key))
const isPrimary = key === row.key
await toBackend.put(key, bytes, {
originalName: isPrimary ? row.original_name : basename(key),
uploadedAt: row.uploaded_at,
contentType: isPrimary ? row.content_type : contentTypeFromKey(key),
size: bytes.length,
userId: row.user_id,
mediaType: isPrimary ? mediaTypeForKey(key) : 'video'
})
if (!await toBackend.head(key)) {
throw new Error(`目标后端写入校验失败:${key}`)
}
}
updateImageBackendId(row.key, toBackend.id)
if (deleteSource) {
for (const key of keys) {
await fromBackend.delete(key)
}
}
}
export async function runBackendSyncJob(
jobId: string,
options: BackendSyncOptions
@@ -88,32 +130,13 @@ export async function runBackendSyncJob(
updateBackupJob(jobId, {
total: rows.length,
progress: 0,
message: '正在同步图片…'
message: '正在同步文件…'
})
let processed = 0
for (const row of rows) {
try {
const stream = await createImageStream(row.key)
const bytes = await streamToBuffer(stream)
await toBackend.put(row.key, bytes, {
originalName: row.original_name,
uploadedAt: row.uploaded_at,
contentType: row.content_type,
size: row.size,
userId: row.user_id
})
const head = await toBackend.head(row.key)
if (!head) {
throw new Error('目标后端写入校验失败')
}
updateImageBackendId(row.key, options.toBackendId)
if (options.deleteSource) {
await fromBackend.delete(row.key)
}
await syncMediaRow(row, fromBackend, toBackend, Boolean(options.deleteSource))
} catch (error) {
failedKeys.push(row.key)
await fs.mkdir(join(failedLogPath, '..'), { recursive: true }).catch(() => {})
@@ -136,7 +159,7 @@ export async function runBackendSyncJob(
}
if (failedKeys.length > 0) {
markJobFailed(jobId, `${failedKeys.length} 张图片同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
markJobFailed(jobId, `${failedKeys.length} 个文件同步失败`, `部分失败:${failedKeys.length}/${rows.length}`)
updateBackupJob(jobId, {
payload: { failedKeys, failedLogPath, preview }
})
@@ -178,22 +201,10 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
const stillFailed: string[] = []
for (const key of keys) {
const row = getImageIndexAsStored(key)
const row = getImageIndexRow(key)
if (!row) continue
try {
const stream = await createImageStream(key)
const bytes = await streamToBuffer(stream)
await toBackend.put(key, bytes, {
originalName: row.originalName,
uploadedAt: row.uploadedAt,
contentType: row.contentType,
size: row.size,
userId: row.userId
})
updateImageBackendId(key, toBackendId)
if (deleteSource) {
await fromBackend.delete(key)
}
await syncMediaRow(row, fromBackend, toBackend, deleteSource)
} catch {
stillFailed.push(key)
}
@@ -205,7 +216,7 @@ export async function retryFailedBackendSync(jobId: string): Promise<void> {
stillFailed.map(key => `${key}\tretry failed\n`).join(''),
'utf8'
)
throw new Error(`仍有 ${stillFailed.length} 张图片同步失败`)
throw new Error(`仍有 ${stillFailed.length} 个文件同步失败`)
}
await fs.rm(failedLogPath, { force: true }).catch(() => {})
+106
View File
@@ -0,0 +1,106 @@
import * as tar from 'tar'
import { describe, expect, it } from 'vitest'
import { ensureDefaultBackends } from '../storage-backends'
import { getActiveBackend } from '../storage/resolver'
import { putImage } from '../storage'
import { updateImageThumb, updateImageTranscode } from '../image-index'
import { createBackupJob, getBackupJob } from './jobs'
import { runFullExportJob } from './full-export'
import { runFullRestoreJob } from './full-restore'
async function archiveEntries(path: string): Promise<string[]> {
const entries: string[] = []
await tar.t({
file: path,
onentry: (entry) => {
if (entry.type === 'File') entries.push(entry.path.replace(/^\.\//, ''))
}
})
return entries
}
describe('runFullExportJob', () => {
it('includes video source, thumbnail and HLS artifacts', async () => {
ensureDefaultBackends()
const key = 'videos/2026/10/backup-test.mp4'
const thumbKey = 'videos/2026/10/backup-test.webp'
const hlsKey = 'videos/2026/10/backup-test/index.m3u8'
const segmentKey = 'videos/2026/10/backup-test/seg000.ts'
const meta = {
originalName: 'backup-test.mp4',
uploadedAt: '2026-10-17T00:00:00.000Z',
contentType: 'video/mp4',
size: 1,
userId: 1,
mediaType: 'video' as const
}
await putImage(key, new Uint8Array([1]), meta)
const backend = await getActiveBackend()
await backend.put(thumbKey, new Uint8Array([2]), meta)
await backend.put(
hlsKey,
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
meta
)
await backend.put(segmentKey, new Uint8Array([3]), meta)
updateImageThumb(key, thumbKey)
updateImageTranscode(key, {
status: 'done',
hlsKey,
progress: 100
})
const job = createBackupJob({ type: 'full_export', message: 'test' })
await runFullExportJob(job.id, 'test')
const done = getBackupJob(job.id)
expect(done?.status).toBe('done')
const entries = await archiveEntries(done!.outputPath!)
expect(entries).toEqual(expect.arrayContaining([
'blobs/videos/2026/10/backup-test.mp4',
'blobs/videos/2026/10/backup-test.webp',
'blobs/videos/2026/10/backup-test/index.m3u8',
'blobs/videos/2026/10/backup-test/seg000.ts'
]))
})
it('restores missing sidecar files in skip mode', async () => {
ensureDefaultBackends()
const key = 'videos/2026/10/restore-test.mp4'
const thumbKey = 'videos/2026/10/restore-test.webp'
const hlsKey = 'videos/2026/10/restore-test/index.m3u8'
const segmentKey = 'videos/2026/10/restore-test/seg000.ts'
const meta = {
originalName: 'restore-test.mp4',
uploadedAt: '2026-10-17T00:00:00.000Z',
contentType: 'video/mp4',
size: 1,
userId: 1,
mediaType: 'video' as const
}
await putImage(key, new Uint8Array([1]), meta)
const backend = await getActiveBackend()
await backend.put(thumbKey, new Uint8Array([2]), meta)
await backend.put(
hlsKey,
new TextEncoder().encode('#EXTM3U\n#EXTINF:6,\nseg000.ts\n'),
meta
)
await backend.put(segmentKey, new Uint8Array([3]), meta)
updateImageThumb(key, thumbKey)
updateImageTranscode(key, { status: 'done', hlsKey, progress: 100 })
const exportJob = createBackupJob({ type: 'full_export', message: 'test' })
await runFullExportJob(exportJob.id, 'test')
const archivePath = getBackupJob(exportJob.id)!.outputPath!
await backend.delete(segmentKey)
expect(await backend.head(segmentKey)).toBeNull()
const restoreJob = createBackupJob({ type: 'full_restore', message: 'test' })
await runFullRestoreJob(restoreJob.id, archivePath, { conflictMode: 'skip' })
expect(await backend.head(segmentKey)).not.toBeNull()
})
})
+12 -39
View File
@@ -1,5 +1,5 @@
import { createWriteStream, promises as fs } from 'node:fs'
import { basename, join } from 'node:path'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import * as tar from 'tar'
import { getImageTotals, listAllImageIndexRows } from '../image-index'
@@ -9,7 +9,7 @@ import {
} from '../storage-backends'
import { parseS3Config } from '../storage/s3'
import { parseTelegramConfig } from '../storage/telegram'
import { createImageStream } from '../storage'
import { getBackendForKey } from '../storage/resolver'
import { copyDatabaseSnapshot } from './db-snapshot'
import { markJobDone, markJobFailed, updateBackupJob } from './jobs'
import { BACKUP_FORMAT_VERSION, type BackupManifest, type StorageBackendMetaExport } from './manifest'
@@ -19,6 +19,7 @@ import {
getBackupsDir
} from './paths'
import type { StorageBackendRow } from '../storage/types'
import { collectMediaAssetKeys } from '../media-assets'
function formatArchiveName(jobId: string): string {
return `pichost-${jobId}${BACKUP_ARCHIVE_EXT}`
@@ -48,36 +49,15 @@ async function writeStreamToFile(stream: NodeJS.ReadableStream, filePath: string
await pipeline(stream, createWriteStream(filePath))
}
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk as Uint8Array))
}
return Buffer.concat(chunks).toString('utf8')
}
/**
* 源文件已按设计删除(转码完成)时,导出 HLS 播放列表、分片与封面。
* 恢复端会遍历 blobs 目录原样还原,因此这里只需把附属文件写进去。
*/
async function exportVideoSidecars(row: ImageIndexRow, stagingRoot: string): Promise<void> {
const keys: string[] = []
if (row.thumb_key) keys.push(row.thumb_key)
if (row.hls_key) {
keys.push(row.hls_key)
const dir = row.hls_key.slice(0, row.hls_key.lastIndexOf('/'))
const text = await streamToString(await createImageStream(row.hls_key))
for (const line of text.split('\n')) {
const name = line.trim()
if (!name || name.startsWith('#')) continue
keys.push(`${dir}/${basename(name)}`)
}
}
async function exportMediaAssets(
row: ImageIndexRow,
stagingRoot: string
): Promise<void> {
const backend = await getBackendForKey(row.key)
const keys = await collectMediaAssetKeys(row, backend)
for (const key of keys) {
await writeStreamToFile(
await createImageStream(key),
await backend.createStream(key),
join(stagingRoot, 'blobs', key)
)
}
@@ -138,17 +118,10 @@ export async function runFullExportJob(jobId: string, appVersion: string): Promi
for (const row of rows) {
updateBackupJob(jobId, {
progress: processed,
message: `正在导出图片 ${processed}/${totals.count + 1}…`
message: `正在导出文件 ${processed}/${totals.count + 1}…`
})
if (Number(row.source_deleted ?? 0) === 1) {
// 转码完成的视频只有 HLS 产物与封面
await exportVideoSidecars(row, stagingRoot)
} else {
const blobPath = join(stagingRoot, 'blobs', row.key)
const stream = await createImageStream(row.key)
await writeStreamToFile(stream, blobPath)
}
await exportMediaAssets(row, stagingRoot)
processed += 1
updateBackupJob(jobId, { progress: processed })
}
+11 -8
View File
@@ -4,6 +4,7 @@ import { pipeline } from 'node:stream/promises'
import * as tar from 'tar'
import { getImageIndexRow } from '../image-index'
import { getActiveBackend } from '../storage/resolver'
import { contentTypeFromKey } from '../content-type'
import { replaceDatabaseFromSnapshot } from './db-snapshot'
import { markJobDone, markJobFailed, restoreBackupJobsAfterReplace, snapshotBackupJobs, updateBackupJob } from './jobs'
import {
@@ -60,10 +61,11 @@ export async function previewFullRestore(archivePath: string): Promise<BackupRes
blobFiles = []
}
const backend = await getActiveBackend()
let conflictingImages = 0
for (const file of blobFiles) {
const rel = relative(blobsRoot, file).replace(/\\/g, '/')
if (getImageIndexRow(rel)) {
if (await backend.head(rel)) {
conflictingImages += 1
}
}
@@ -104,7 +106,7 @@ export async function runFullRestoreJob(
restoreBackupJobsAfterReplace(preservedJobs)
updateBackupJob(jobId, {
status: 'running',
message: '正在恢复图片…',
message: '正在恢复文件…',
progress: 0,
total: 0
})
@@ -112,7 +114,7 @@ export async function runFullRestoreJob(
const blobsRoot = join(stagingRoot, 'blobs')
const blobFiles = await walkFiles(blobsRoot)
const total = blobFiles.length + 1
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复图片…' })
updateBackupJob(jobId, { total, progress: 1, message: '正在恢复文件…' })
const backend = await getActiveBackend()
let processed = 1
@@ -120,8 +122,9 @@ export async function runFullRestoreJob(
for (const file of blobFiles) {
const key = relative(blobsRoot, file).replace(/\\/g, '/')
const indexed = getImageIndexRow(key)
const exists = Boolean(await backend.head(key))
if (!indexed && options.conflictMode === 'skip') {
if (exists && options.conflictMode === 'skip') {
processed += 1
continue
}
@@ -138,19 +141,19 @@ export async function runFullRestoreJob(
: {
originalName: key.split('/').pop() ?? key,
uploadedAt: new Date().toISOString(),
contentType: 'application/octet-stream',
contentType: contentTypeFromKey(key),
size: bytes.length,
userId: null
}
if (options.conflictMode === 'overwrite' || !indexed) {
if (options.conflictMode === 'overwrite' || !exists) {
await backend.put(key, bytes, meta)
}
processed += 1
updateBackupJob(jobId, {
progress: processed,
message: `正在恢复图片 ${processed}/${total}…`
message: `正在恢复文件 ${processed}/${total}…`
})
}
@@ -159,7 +162,7 @@ export async function runFullRestoreJob(
markJobDone(jobId, {
message: '恢复完成,请重新登录并在存储页检查对象存储密钥',
payload: {
restoredImages: blobFiles.length,
restoredFiles: blobFiles.length,
manifest
}
})
+58 -25
View File
@@ -1,5 +1,5 @@
import { randomBytes } from 'node:crypto'
import { getDb } from '../db'
import { getDb, getPrimaryAdminUserId } from '../db'
import { ensureBackupSchema } from './schema'
export type BackupJobType = 'full_export' | 'full_restore' | 'backend_sync'
@@ -7,6 +7,7 @@ export type BackupJobStatus = 'pending' | 'running' | 'done' | 'failed'
export interface BackupJob {
id: string
userId: number | null
type: BackupJobType
status: BackupJobStatus
progress: number
@@ -21,6 +22,7 @@ export interface BackupJob {
interface BackupJobRow {
id: string
user_id: number | null
type: BackupJobType
status: BackupJobStatus
progress: number
@@ -43,6 +45,7 @@ function rowToJob(row: BackupJobRow): BackupJob {
})()
return {
id: row.id,
userId: row.user_id ?? null,
type: row.type,
status: row.status,
progress: row.progress,
@@ -62,33 +65,34 @@ function generateJobId(): string {
return `${stamp}-${suffix}`
}
export function getRunningBackupJob(): BackupJob | null {
export function getRunningBackupJob(userId?: number): BackupJob | null {
ensureBackupSchema()
const row = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE status = 'running'
WHERE status = 'running'${userId === undefined ? '' : ' AND user_id = ?'}
LIMIT 1
`).get() as BackupJobRow | undefined
`).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null
}
export function getActiveBackupJob(): BackupJob | null {
export function getActiveBackupJob(userId?: number): BackupJob | null {
ensureBackupSchema()
const row = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE status IN ('pending', 'running')
WHERE status IN ('pending', 'running')${userId === undefined ? '' : ' AND user_id = ?'}
ORDER BY created_at DESC
LIMIT 1
`).get() as BackupJobRow | undefined
`).get(...(userId === undefined ? [] : [userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null
}
export function createBackupJob(input: {
type: BackupJobType
userId?: number | null
total?: number
message?: string
payload?: Record<string, unknown>
@@ -97,13 +101,15 @@ export function createBackupJob(input: {
const id = generateJobId()
const now = new Date().toISOString()
const payloadJson = JSON.stringify(input.payload ?? {})
const userId = input.userId === undefined ? getPrimaryAdminUserId() : input.userId
getDb().prepare(`
INSERT INTO backup_jobs
(id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL)
(id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?, NULL, NULL, ?, NULL)
`).run(
id,
userId,
input.type,
input.total ?? 0,
input.message ?? '',
@@ -114,43 +120,69 @@ export function createBackupJob(input: {
return getBackupJob(id)!
}
export function getBackupJob(id: string): BackupJob | null {
export function getBackupJob(id: string, userId?: number): BackupJob | null {
ensureBackupSchema()
const row = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE id = ?
`).get(id) as BackupJobRow | undefined
WHERE id = ?${userId === undefined ? '' : ' AND user_id = ?'}
`).get(...(userId === undefined ? [id] : [id, userId])) as BackupJobRow | undefined
return row ? rowToJob(row) : null
}
export function listBackupJobs(limit = 20): BackupJob[] {
return listBackupJobsPage(1, limit).jobs
export function listBackupJobs(limit = 20, userId?: number): BackupJob[] {
return listBackupJobsPage(1, limit, userId).jobs
}
export function countBackupJobs(): number {
export function countBackupJobs(userId?: number): number {
ensureBackupSchema()
const row = getDb().prepare('SELECT COUNT(*) AS count FROM backup_jobs').get() as { count: number }
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM backup_jobs
${userId === undefined ? '' : 'WHERE user_id = ?'}
`).get(...(userId === undefined ? [] : [userId])) as { count: number }
return row.count
}
export function listBackupJobsPage(page = 1, pageSize = 10): { jobs: BackupJob[], total: number } {
export function listBackupJobsPage(
page = 1,
pageSize = 10,
userId?: number
): { jobs: BackupJob[], total: number } {
ensureBackupSchema()
const limit = Math.min(50, Math.max(1, pageSize))
const safePage = Math.max(1, page)
const offset = (safePage - 1) * limit
const total = countBackupJobs()
const total = countBackupJobs(userId)
const rows = getDb().prepare(`
SELECT id, type, status, progress, total, message, payload_json,
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
${userId === undefined ? '' : 'WHERE user_id = ?'}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as unknown as BackupJobRow[]
`).all(...(userId === undefined ? [limit, offset] : [userId, limit, offset])) as unknown as BackupJobRow[]
return { jobs: rows.map(rowToJob), total }
}
export function listAllBackupJobsForUser(userId: number): BackupJob[] {
ensureBackupSchema()
const rows = getDb().prepare(`
SELECT id, user_id, type, status, progress, total, message, payload_json,
output_path, error, created_at, finished_at
FROM backup_jobs
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId) as unknown as BackupJobRow[]
return rows.map(rowToJob)
}
export function deleteBackupJobsForUser(userId: number): number {
ensureBackupSchema()
const result = getDb().prepare('DELETE FROM backup_jobs WHERE user_id = ?').run(userId)
return Number(result.changes ?? 0)
}
/** Capture live task history before a full restore swaps the database file. */
export function snapshotBackupJobs(limit = 50): BackupJob[] {
return listBackupJobs(limit)
@@ -165,12 +197,13 @@ export function restoreBackupJobsAfterReplace(preserved: BackupJob[]): void {
db.prepare('DELETE FROM backup_jobs').run()
const insert = db.prepare(`
INSERT INTO backup_jobs
(id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(id, user_id, type, status, progress, total, message, payload_json, output_path, error, created_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
for (const job of preserved) {
insert.run(
job.id,
job.userId,
job.type,
job.status,
job.progress,
+7 -3
View File
@@ -70,10 +70,11 @@ export function scheduleBackupJob(
})
}
export function startFullExportJob(appVersion: string): BackupJob {
export function startFullExportJob(appVersion: string, userId?: number | null): BackupJob {
claimJobSlot()
const job = createBackupJob({
type: 'full_export',
userId,
message: '等待开始…'
})
scheduleBackupJob(job, () => runFullExportJob(job.id, appVersion))
@@ -82,11 +83,13 @@ export function startFullExportJob(appVersion: string): BackupJob {
export function startFullRestoreJob(
archivePath: string,
conflictMode: 'skip' | 'overwrite'
conflictMode: 'skip' | 'overwrite',
userId?: number | null
): BackupJob {
claimJobSlot()
const job = createBackupJob({
type: 'full_restore',
userId,
message: '等待开始…',
payload: { archivePath, conflictMode }
})
@@ -100,10 +103,11 @@ export function startBackendSyncJob(options: {
deleteSource?: boolean
setDefault?: boolean
dryRun?: boolean
}): BackupJob {
}, userId?: number | null): BackupJob {
claimJobSlot()
const job = createBackupJob({
type: 'backend_sync',
userId,
message: options.dryRun ? '预检中…' : '等待开始…',
payload: options
})
+18
View File
@@ -4,6 +4,7 @@ export function ensureBackupSchema(): void {
getDb().exec(`
CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY,
user_id INTEGER,
type TEXT NOT NULL CHECK(type IN ('full_export', 'full_restore', 'backend_sync')),
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'done', 'failed')),
progress INTEGER NOT NULL DEFAULT 0,
@@ -18,4 +19,21 @@ export function ensureBackupSchema(): void {
CREATE INDEX IF NOT EXISTS idx_backup_jobs_created_at
ON backup_jobs(created_at DESC);
`)
const columns = getDb().prepare('PRAGMA table_info(backup_jobs)').all() as Array<{
name: string
}>
if (!columns.some(column => column.name === 'user_id')) {
getDb().exec('ALTER TABLE backup_jobs ADD COLUMN user_id INTEGER')
}
getDb().exec(`
CREATE INDEX IF NOT EXISTS idx_backup_jobs_user_created
ON backup_jobs(user_id, created_at DESC);
UPDATE backup_jobs
SET user_id = (
SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1
)
WHERE user_id IS NULL
AND EXISTS (SELECT 1 FROM users WHERE role = 'admin');
`)
}