feat(db): онлайн-бэкап SQLite, CLI backup, ротация и восстановление

This commit is contained in:
Denozordec committed 2026-10-23 17:02:50 +07:00
1 parent 8298454c63
commit 8cba7a5c41
9 files changed
+345 -2

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
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 }
}
+1
View File
@@ -270,6 +270,7 @@ export function healthCheck(sqlite: Sqlite): void {
export * from './schema/index.js'
export * from './tx.js'
export * from './backup.js'
export * from './generation.js'
export * from './housekeeping.js'
export * from './users.js'
+105
View File
@@ -0,0 +1,105 @@
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, describe, expect, it } from 'vitest'
import {
backupDatabase,
checkIntegrity,
closeDb,
createDb,
createUser,
getUserByEmail,
listBackups,
migrateSchema,
} from '../src/index.js'
const dirs: string[] = []
const dbs: Array<ReturnType<typeof createDb>['sqlite']> = []
afterEach(() => {
for (const s of dbs.splice(0)) closeDb(s)
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
function fileDb() {
const dir = mkdtempSync(join(tmpdir(), 'ap-bk-'))
dirs.push(dir)
const r = createDb(join(dir, 'app.db'))
migrateSchema(r.sqlite)
dbs.push(r.sqlite)
return { dir, ...r }
}
describe('backupDatabase', () => {
it('создаёт консистентную копию с данными из WAL', async () => {
const { dir, db, sqlite } = fileDb()
createUser(db, { email: 'a@x.io', name: 'A', passwordHash: 'h', isAdmin: false })
const res = await backupDatabase(sqlite, { dir: join(dir, 'backups'), keep: 3 })
expect(existsSync(res.file)).toBe(true)
expect(checkIntegrity(res.file)).toBe(true)
const copy = new Database(res.file, { readonly: true })
const row = copy.prepare('SELECT email FROM users').get()
copy.close()
expect(row).toEqual({ email: 'a@x.io' })
})
it('бэкап во время записи остаётся валидным', async () => {
const { dir, db, sqlite } = fileDb()
const writer = setInterval(() => {
try {
createUser(db, {
email: `u${Math.random()}@x.io`,
name: 'U',
passwordHash: 'h',
isAdmin: false,
})
} catch {
/* БД могла закрыться */
}
}, 1)
const res = await backupDatabase(sqlite, { dir, keep: 3 })
clearInterval(writer)
expect(checkIntegrity(res.file)).toBe(true)
})
it('ротация оставляет keep последних', async () => {
const { dir, sqlite } = fileDb()
const bdir = join(dir, 'b')
for (let i = 0; i < 5; i++) {
await backupDatabase(sqlite, {
dir: bdir,
keep: 2,
now: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
})
}
const left = listBackups(bdir)
expect(left).toHaveLength(2)
expect(left[0]).toContain('000004')
})
it('восстановленная копия открывается и содержит данные', async () => {
const { dir, db, sqlite } = fileDb()
createUser(db, { email: 'r@x.io', name: 'R', passwordHash: 'h', isAdmin: false })
const res = await backupDatabase(sqlite, { dir, keep: 1 })
const restored = createDb(res.file)
dbs.push(restored.sqlite)
migrateSchema(restored.sqlite)
expect(getUserByEmail(restored.db, 'r@x.io')?.name).toBe('R')
})
it('битый файл не проходит integrity_check', () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-bad-'))
dirs.push(dir)
const p = join(dir, 'bad.db')
writeFileSync(p, 'not a sqlite file'.repeat(100))
expect(() => checkIntegrity(p)).toThrow()
})
it('ошибка бэкапа не оставляет .partial', async () => {
const { dir, sqlite } = fileDb()
closeDb(sqlite)
await expect(backupDatabase(sqlite, { dir: join(dir, 'x'), keep: 1 })).rejects.toThrow()
expect(listBackups(join(dir, 'x'))).toHaveLength(0)
})
})