feat(db): онлайн-бэкап SQLite, CLI backup, ротация и восстановление
This commit is contained in:
1 parent
8298454c63
commit
8cba7a5c41
9 files changed
+345
-2
No files matched your search
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user