feat(backup): atomic SQLite snapshot to inkling-YYYY-MM-DD.sqlite
KST date filename, tmp+rename atomic write, mkdir on demand. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
src/main/services/BackupService.ts
Normal file
55
src/main/services/BackupService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { mkdir, rename, stat, readdir, unlink } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { applyGfsRetention } from './backupRotation.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
function toKstDateKey(d: Date): string {
|
||||
const k = new Date(d.getTime() + KST_OFFSET_MS);
|
||||
return k.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export interface SnapshotResult {
|
||||
path: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface RotateResult {
|
||||
kept: string[];
|
||||
removed: string[];
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
constructor(
|
||||
private db: Database.Database,
|
||||
private backupDir: string,
|
||||
private now: () => Date = () => new Date()
|
||||
) {}
|
||||
|
||||
async snapshot(): Promise<SnapshotResult> {
|
||||
await mkdir(this.backupDir, { recursive: true });
|
||||
const dateKey = toKstDateKey(this.now());
|
||||
const finalPath = join(this.backupDir, `inkling-${dateKey}.sqlite`);
|
||||
const tmpPath = `${finalPath}.tmp`;
|
||||
await this.db.backup(tmpPath);
|
||||
await rename(tmpPath, finalPath);
|
||||
const st = await stat(finalPath);
|
||||
return { path: finalPath, bytes: st.size };
|
||||
}
|
||||
|
||||
async rotate(): Promise<RotateResult> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(this.backupDir);
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { kept: [], removed: [] };
|
||||
throw e;
|
||||
}
|
||||
const decision = applyGfsRetention(entries, this.now());
|
||||
for (const name of decision.remove) {
|
||||
await unlink(join(this.backupDir, name));
|
||||
}
|
||||
return { kept: decision.keep, removed: decision.remove };
|
||||
}
|
||||
}
|
||||
78
tests/unit/BackupService.test.ts
Normal file
78
tests/unit/BackupService.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { BackupService } from '@main/services/BackupService.js';
|
||||
|
||||
describe('BackupService.snapshot', () => {
|
||||
let dir: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'inkling-backup-'));
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
db.prepare(
|
||||
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES (?, ?, 'pending', ?, ?)`
|
||||
).run('n1', 'hello', '2026-04-26T00:00:00Z', '2026-04-26T00:00:00Z');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes inkling-YYYY-MM-DD.sqlite (KST date) to backupDir', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z')); // 21:00 KST
|
||||
const r = await svc.snapshot();
|
||||
expect(r.path).toBe(join(dir, 'inkling-2026-04-26.sqlite'));
|
||||
expect(existsSync(r.path)).toBe(true);
|
||||
expect(r.bytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses KST date even when UTC date differs (around midnight)', async () => {
|
||||
// 2026-04-26 23:30 UTC = 2026-04-27 08:30 KST
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T23:30:00Z'));
|
||||
const r = await svc.snapshot();
|
||||
expect(r.path).toBe(join(dir, 'inkling-2026-04-27.sqlite'));
|
||||
});
|
||||
|
||||
it('overwrites same-day backup atomically (no partial files left)', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
await svc.snapshot();
|
||||
await svc.snapshot();
|
||||
const files = readdirSync(dir).filter((f) => f.startsWith('inkling-'));
|
||||
expect(files).toEqual(['inkling-2026-04-26.sqlite']);
|
||||
// No leftover .tmp files
|
||||
expect(readdirSync(dir).some((f) => f.endsWith('.tmp'))).toBe(false);
|
||||
});
|
||||
|
||||
it('snapshot file is a valid SQLite DB containing the source row', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
const r = await svc.snapshot();
|
||||
const restored = new Database(r.path, { readonly: true });
|
||||
const row = restored.prepare('SELECT id, raw_text FROM notes').get() as
|
||||
| { id: string; raw_text: string }
|
||||
| undefined;
|
||||
expect(row?.id).toBe('n1');
|
||||
expect(row?.raw_text).toBe('hello');
|
||||
restored.close();
|
||||
});
|
||||
|
||||
it('creates backupDir if it does not exist', async () => {
|
||||
const fresh = join(dir, 'nested', 'backups');
|
||||
expect(existsSync(fresh)).toBe(false);
|
||||
const svc = new BackupService(db, fresh, () => new Date('2026-04-26T12:00:00Z'));
|
||||
await svc.snapshot();
|
||||
expect(existsSync(fresh)).toBe(true);
|
||||
});
|
||||
|
||||
it('snapshot file is not zero bytes (regression: empty backup)', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
const r = await svc.snapshot();
|
||||
expect(statSync(r.path).size).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user