67 lines
2.1 KiB
TypeScript
67 lines
2.1 KiB
TypeScript
import { mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import Database from 'better-sqlite3'
|
||
import type { Sqlite } from './index.js'
|
||
|
||
const BACKUP_RE = /^app-\d{8}-\d{6}-\d{3}\.db$/
|
||
|
||
function stamp(d: Date): string {
|
||
const p = (n: number, w = 2) => String(n).padStart(w, '0')
|
||
return (
|
||
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
|
||
`-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}` +
|
||
`-${p(d.getUTCMilliseconds(), 3)}`
|
||
)
|
||
}
|
||
|
||
/** PRAGMA integrity_check для файла БД; true, если результат 'ok'. */
|
||
export function checkIntegrity(path: string): boolean {
|
||
const db = new Database(path, { readonly: true, fileMustExist: true })
|
||
try {
|
||
return db.pragma('integrity_check', { simple: true }) === 'ok'
|
||
} finally {
|
||
db.close()
|
||
}
|
||
}
|
||
|
||
export function listBackups(dir: string): string[] {
|
||
try {
|
||
return readdirSync(dir)
|
||
.filter((f) => BACKUP_RE.test(f))
|
||
.sort()
|
||
.reverse()
|
||
} catch {
|
||
return []
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Согласованный онлайн-бэкап (better-sqlite3 backup API работает с WAL без
|
||
* остановки сервиса), проверка integrity_check, ротация по числу копий.
|
||
*/
|
||
export async function backupDatabase(
|
||
sqlite: Sqlite,
|
||
opts: { dir: string; keep: number; now?: Date },
|
||
): Promise<{ file: string; removed: string[] }> {
|
||
mkdirSync(opts.dir, { recursive: true })
|
||
const name = `app-${stamp(opts.now ?? new Date())}.db`
|
||
const finalPath = join(opts.dir, name)
|
||
const partial = `${finalPath}.partial`
|
||
try {
|
||
await sqlite.backup(partial)
|
||
if (!checkIntegrity(partial)) {
|
||
throw new Error('backup integrity_check failed')
|
||
}
|
||
renameSync(partial, finalPath)
|
||
} catch (err) {
|
||
rmSync(partial, { force: true })
|
||
throw err
|
||
}
|
||
const removed: string[] = []
|
||
for (const old of listBackups(opts.dir).slice(Math.max(1, opts.keep))) {
|
||
rmSync(join(opts.dir, old), { force: true })
|
||
removed.push(old)
|
||
}
|
||
return { file: finalPath, removed }
|
||
}
|