docs(plan): F6-L1 local snapshot 구현 계획 (TDD, 5 tasks)
Task 1: 순수 GFS retention 함수 + 7 단위 테스트 Task 2: BackupService.snapshot() — KST 날짜·tmp+rename 원자성 + 6 단위 테스트 Task 3: runDaily() — .last-snapshot 마커 + lastSnapshotAt + 7 단위 테스트 Task 4: main/index.ts wiring (whenReady + before-quit) + tray '지금 백업' Task 5: F6-L1 promotion (별 spec 분기 + dogfood-feedback.md 상태 갱신) backup 위치: <profileDir>/backups/ (mini-brainstorm 결과 A 채택). 스키마 변경 0, 외부 dep 0. better-sqlite3.backup() API 가정. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
962
docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md
Normal file
962
docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md
Normal file
@@ -0,0 +1,962 @@
|
||||
# F6-L1 Local Snapshot Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Atomic SQLite snapshot to `<profileDir>/backups/` with GFS retention (14 daily · 4 weekly · 6 monthly), tray "지금 백업" entry, and on-quit + on-startup daily triggers — protecting dogfood data against accidental delete, DB corruption, and bad migrations.
|
||||
|
||||
**Architecture:** A pure rotation function (`applyGfsRetention`) drives retention math without filesystem deps. `BackupService` orchestrates `db.backup()` writes via atomic temp-file + rename, plus marker-file gating to skip redundant same-day backups. Service instantiated in `main/index.ts`, wired to `app.whenReady` (daily check) + `before-quit` (final flush) + tray callback.
|
||||
|
||||
**Tech Stack:** TypeScript, better-sqlite3 12.9.0 (`db.backup()` async API), Electron 41.3.0 (`app.on('before-quit')`), vitest 4.1.5, Node `fs/promises`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
- `src/main/services/backupRotation.ts` — pure GFS retention function
|
||||
- `src/main/services/BackupService.ts` — orchestrator (snapshot + rotate + runDaily)
|
||||
- `tests/unit/backupRotation.test.ts` — rotation policy tests
|
||||
- `tests/unit/BackupService.test.ts` — service-level tests with real :memory: DB + tmp dir
|
||||
|
||||
**Modify:**
|
||||
- `src/main/index.ts` — wire BackupService, schedule whenReady + before-quit, pass callback to tray
|
||||
- `src/main/tray.ts` — add "지금 백업" menu item, accept `runBackup` callback
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F6-L1 status 🌱 → 🚀 promoted, add link to spec
|
||||
|
||||
**No schema changes. No new dependencies.**
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pure GFS Retention Function
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/services/backupRotation.ts`
|
||||
- Test: `tests/unit/backupRotation.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for filename parsing**
|
||||
|
||||
```typescript
|
||||
// tests/unit/backupRotation.test.ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBackupFilename, applyGfsRetention } from '@main/services/backupRotation.js';
|
||||
|
||||
describe('parseBackupFilename', () => {
|
||||
it('extracts ISO date from valid filename', () => {
|
||||
expect(parseBackupFilename('inkling-2026-04-26.sqlite')).toBe('2026-04-26');
|
||||
});
|
||||
|
||||
it('returns null for non-matching filename', () => {
|
||||
expect(parseBackupFilename('something-else.sqlite')).toBeNull();
|
||||
expect(parseBackupFilename('inkling-2026-13-99.sqlite')).toBeNull();
|
||||
expect(parseBackupFilename('.last-snapshot')).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test, expect fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/backupRotation.test.ts`
|
||||
Expected: FAIL — `Cannot find module '@main/services/backupRotation.js'`
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
```typescript
|
||||
// src/main/services/backupRotation.ts
|
||||
const BACKUP_FILENAME_REGEX = /^inkling-(\d{4}-\d{2}-\d{2})\.sqlite$/;
|
||||
|
||||
export function parseBackupFilename(name: string): string | null {
|
||||
const m = BACKUP_FILENAME_REGEX.exec(name);
|
||||
if (!m) return null;
|
||||
const iso = m[1]!;
|
||||
const d = new Date(iso + 'T00:00:00Z');
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== iso) return null;
|
||||
return iso;
|
||||
}
|
||||
|
||||
export interface RetentionResult {
|
||||
keep: string[];
|
||||
remove: string[];
|
||||
}
|
||||
|
||||
export function applyGfsRetention(
|
||||
_filenames: string[],
|
||||
_now: Date
|
||||
): RetentionResult {
|
||||
return { keep: [], remove: [] };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run, expect pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/backupRotation.test.ts`
|
||||
Expected: PASS (parseBackupFilename tests). `applyGfsRetention` tests not yet written.
|
||||
|
||||
- [ ] **Step 5: Write failing tests for GFS retention**
|
||||
|
||||
Append to `tests/unit/backupRotation.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('applyGfsRetention', () => {
|
||||
// KST-naive logic — caller passes UTC `now`. Filenames are KST date keys.
|
||||
const NOW = new Date('2026-04-26T12:00:00Z'); // 2026-04-26 21:00 KST
|
||||
|
||||
function names(...dates: string[]): string[] {
|
||||
return dates.map((d) => `inkling-${d}.sqlite`);
|
||||
}
|
||||
|
||||
it('keeps files within last 14 days (daily window)', () => {
|
||||
const files = names(
|
||||
'2026-04-26', '2026-04-25', '2026-04-20', '2026-04-13', '2026-04-12'
|
||||
);
|
||||
const r = applyGfsRetention(files, NOW);
|
||||
// 14 day window from 2026-04-26 reaches back to 2026-04-13 inclusive.
|
||||
expect(r.keep).toContain('inkling-2026-04-26.sqlite');
|
||||
expect(r.keep).toContain('inkling-2026-04-25.sqlite');
|
||||
expect(r.keep).toContain('inkling-2026-04-20.sqlite');
|
||||
expect(r.keep).toContain('inkling-2026-04-13.sqlite');
|
||||
expect(r.remove).toContain('inkling-2026-04-12.sqlite');
|
||||
});
|
||||
|
||||
it('keeps last 4 Mondays beyond the 14 day window', () => {
|
||||
// Mondays in 2026: 04-13, 04-06, 03-30, 03-23, 03-16, 03-09
|
||||
const files = names(
|
||||
'2026-04-13', // within 14-day, also a Monday
|
||||
'2026-04-06', // outside 14-day, but a Monday in last 4 weeks
|
||||
'2026-03-30', // a Monday in last 4 weeks
|
||||
'2026-03-23', // a Monday in last 4 weeks
|
||||
'2026-03-16', // a Monday more than 4 weeks ago — REMOVE unless month-1
|
||||
'2026-03-09' // a Monday more than 4 weeks ago — REMOVE
|
||||
);
|
||||
const r = applyGfsRetention(files, NOW);
|
||||
expect(r.keep).toContain('inkling-2026-04-06.sqlite');
|
||||
expect(r.keep).toContain('inkling-2026-03-30.sqlite');
|
||||
expect(r.keep).toContain('inkling-2026-03-23.sqlite');
|
||||
expect(r.remove).toContain('inkling-2026-03-16.sqlite');
|
||||
expect(r.remove).toContain('inkling-2026-03-09.sqlite');
|
||||
});
|
||||
|
||||
it('keeps month-firsts within last 6 months', () => {
|
||||
// Last 6 month-firsts from 2026-04-26: 2026-04-01, 2026-03-01, 2026-02-01,
|
||||
// 2026-01-01, 2025-12-01, 2025-11-01
|
||||
const files = names(
|
||||
'2026-04-01', // within 14-day already
|
||||
'2026-03-01', // outside 14-day, outside 4-week-Monday — keep via month rule
|
||||
'2026-02-01',
|
||||
'2026-01-01',
|
||||
'2025-12-01',
|
||||
'2025-11-01',
|
||||
'2025-10-01' // outside 6-month window — REMOVE
|
||||
);
|
||||
const r = applyGfsRetention(files, NOW);
|
||||
expect(r.keep).toContain('inkling-2026-03-01.sqlite');
|
||||
expect(r.keep).toContain('inkling-2026-02-01.sqlite');
|
||||
expect(r.keep).toContain('inkling-2025-11-01.sqlite');
|
||||
expect(r.remove).toContain('inkling-2025-10-01.sqlite');
|
||||
});
|
||||
|
||||
it('ignores files that do not match backup pattern', () => {
|
||||
const files = ['random.sqlite', 'inkling.sqlite', '.last-snapshot', 'inkling-bad-date.sqlite'];
|
||||
const r = applyGfsRetention(files, NOW);
|
||||
expect(r.keep).toEqual([]);
|
||||
expect(r.remove).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps future-dated files (clock skew safety)', () => {
|
||||
const files = names('2030-01-01');
|
||||
const r = applyGfsRetention(files, NOW);
|
||||
expect(r.keep).toContain('inkling-2030-01-01.sqlite');
|
||||
expect(r.remove).toEqual([]);
|
||||
});
|
||||
|
||||
it('a file kept by any rule is in keep, never in both lists', () => {
|
||||
const files = names('2026-04-26', '2026-04-13', '2026-03-23', '2026-03-01');
|
||||
const r = applyGfsRetention(files, NOW);
|
||||
const intersection = r.keep.filter((f) => r.remove.includes(f));
|
||||
expect(intersection).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run, expect fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/backupRotation.test.ts`
|
||||
Expected: FAIL — applyGfsRetention returns empty arrays.
|
||||
|
||||
- [ ] **Step 7: Implement applyGfsRetention**
|
||||
|
||||
Replace the stub in `src/main/services/backupRotation.ts`:
|
||||
|
||||
```typescript
|
||||
const BACKUP_FILENAME_REGEX = /^inkling-(\d{4}-\d{2}-\d{2})\.sqlite$/;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DAILY_WINDOW_DAYS = 14;
|
||||
const WEEKLY_WINDOW_COUNT = 4;
|
||||
const MONTHLY_WINDOW_COUNT = 6;
|
||||
|
||||
export function parseBackupFilename(name: string): string | null {
|
||||
const m = BACKUP_FILENAME_REGEX.exec(name);
|
||||
if (!m) return null;
|
||||
const iso = m[1]!;
|
||||
const d = new Date(iso + 'T00:00:00Z');
|
||||
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== iso) return null;
|
||||
return iso;
|
||||
}
|
||||
|
||||
export interface RetentionResult {
|
||||
keep: string[];
|
||||
remove: string[];
|
||||
}
|
||||
|
||||
function isoDateUtc(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function startOfDayUtc(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setUTCHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function isWithinDailyWindow(fileDate: Date, now: Date): boolean {
|
||||
const today = startOfDayUtc(now);
|
||||
const oldest = new Date(today.getTime() - (DAILY_WINDOW_DAYS - 1) * ONE_DAY_MS);
|
||||
return fileDate >= oldest && fileDate <= today;
|
||||
}
|
||||
|
||||
function isWithinWeeklyWindow(fileDate: Date, now: Date): boolean {
|
||||
// UTC-based Monday detection. UTCDay: 0=Sun, 1=Mon..6=Sat
|
||||
if (fileDate.getUTCDay() !== 1) return false;
|
||||
const today = startOfDayUtc(now);
|
||||
const oldest = new Date(today.getTime() - WEEKLY_WINDOW_COUNT * 7 * ONE_DAY_MS);
|
||||
return fileDate >= oldest && fileDate <= today;
|
||||
}
|
||||
|
||||
function isWithinMonthlyWindow(fileDate: Date, now: Date): boolean {
|
||||
if (fileDate.getUTCDate() !== 1) return false;
|
||||
const today = startOfDayUtc(now);
|
||||
// months ago: difference in calendar months
|
||||
const monthsAgo =
|
||||
(today.getUTCFullYear() - fileDate.getUTCFullYear()) * 12 +
|
||||
(today.getUTCMonth() - fileDate.getUTCMonth());
|
||||
return monthsAgo >= 0 && monthsAgo < MONTHLY_WINDOW_COUNT;
|
||||
}
|
||||
|
||||
export function applyGfsRetention(filenames: string[], now: Date): RetentionResult {
|
||||
const keep: string[] = [];
|
||||
const remove: string[] = [];
|
||||
for (const name of filenames) {
|
||||
const iso = parseBackupFilename(name);
|
||||
if (iso === null) continue; // unrecognized — ignore (no-op)
|
||||
const fileDate = new Date(iso + 'T00:00:00Z');
|
||||
if (fileDate > startOfDayUtc(now)) {
|
||||
keep.push(name); // future-dated — clock skew safety
|
||||
continue;
|
||||
}
|
||||
const survives =
|
||||
isWithinDailyWindow(fileDate, now) ||
|
||||
isWithinWeeklyWindow(fileDate, now) ||
|
||||
isWithinMonthlyWindow(fileDate, now);
|
||||
if (survives) keep.push(name);
|
||||
else remove.push(name);
|
||||
}
|
||||
return { keep, remove };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run all tests for this file, expect pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/backupRotation.test.ts`
|
||||
Expected: PASS — all parseBackupFilename + applyGfsRetention tests green.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/services/backupRotation.ts tests/unit/backupRotation.test.ts
|
||||
git commit -m "feat(backup): GFS retention policy (pure)
|
||||
|
||||
14 daily + 4 weekly (Mondays) + 6 monthly (1st). Future-dated files
|
||||
preserved (clock skew). Unrecognized filenames ignored (no delete)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: BackupService — snapshot()
|
||||
|
||||
**Files:**
|
||||
- Create: `src/main/services/BackupService.ts`
|
||||
- Test: `tests/unit/BackupService.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for snapshot()**
|
||||
|
||||
```typescript
|
||||
// tests/unit/BackupService.test.ts
|
||||
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);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, expect fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/BackupService.test.ts`
|
||||
Expected: FAIL — `Cannot find module '@main/services/BackupService.js'`
|
||||
|
||||
- [ ] **Step 3: Implement BackupService.snapshot**
|
||||
|
||||
```typescript
|
||||
// src/main/services/BackupService.ts
|
||||
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 };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run snapshot tests, expect pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/BackupService.test.ts -t snapshot`
|
||||
Expected: PASS — all 6 snapshot tests green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/services/BackupService.ts tests/unit/BackupService.test.ts
|
||||
git commit -m "feat(backup): atomic SQLite snapshot to inkling-YYYY-MM-DD.sqlite
|
||||
|
||||
KST date filename, tmp+rename atomic write, mkdir on demand."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: BackupService — runDaily() with marker
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/services/BackupService.ts`
|
||||
- Modify: `tests/unit/BackupService.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for runDaily**
|
||||
|
||||
Append to `tests/unit/BackupService.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
describe('BackupService.runDaily', () => {
|
||||
let dir: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'inkling-backup-'));
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('snapshots when marker is absent', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
const r = await svc.runDaily();
|
||||
expect(r.snapshotted).toBe(true);
|
||||
expect(existsSync(join(dir, '.last-snapshot'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'inkling-2026-04-26.sqlite'))).toBe(true);
|
||||
});
|
||||
|
||||
it('skips when marker shows today already snapshotted', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
await svc.runDaily(); // first
|
||||
const r = await svc.runDaily(); // second same day
|
||||
expect(r.snapshotted).toBe(false);
|
||||
expect(r.reason).toMatch(/already/);
|
||||
});
|
||||
|
||||
it('snapshots again when marker shows different date', async () => {
|
||||
// Pre-seed marker as yesterday
|
||||
const dir2 = dir;
|
||||
await new BackupService(db, dir2, () => new Date('2026-04-25T12:00:00Z')).runDaily();
|
||||
const svc = new BackupService(db, dir2, () => new Date('2026-04-26T12:00:00Z'));
|
||||
const r = await svc.runDaily();
|
||||
expect(r.snapshotted).toBe(true);
|
||||
expect(existsSync(join(dir2, 'inkling-2026-04-26.sqlite'))).toBe(true);
|
||||
expect(existsSync(join(dir2, 'inkling-2026-04-25.sqlite'))).toBe(true);
|
||||
});
|
||||
|
||||
it('runs rotation after snapshot', async () => {
|
||||
// Pre-create an old file that should be rotated out
|
||||
const ancient = join(dir, 'inkling-2024-01-01.sqlite');
|
||||
writeFileSync(ancient, 'fake');
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
const r = await svc.runDaily();
|
||||
expect(r.snapshotted).toBe(true);
|
||||
expect(r.removed).toContain('inkling-2024-01-01.sqlite');
|
||||
expect(existsSync(ancient)).toBe(false);
|
||||
});
|
||||
|
||||
it('marker contains ISO date matching the snapshot file', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
await svc.runDaily();
|
||||
const marker = readFileSync(join(dir, '.last-snapshot'), 'utf8').trim();
|
||||
expect(marker).toBe('2026-04-26');
|
||||
});
|
||||
|
||||
it('lastSnapshotAt returns null when marker absent', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
expect(await svc.lastSnapshotAt()).toBeNull();
|
||||
});
|
||||
|
||||
it('lastSnapshotAt returns marker date when present', async () => {
|
||||
const svc = new BackupService(db, dir, () => new Date('2026-04-26T12:00:00Z'));
|
||||
await svc.runDaily();
|
||||
expect(await svc.lastSnapshotAt()).toBe('2026-04-26');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, expect fail**
|
||||
|
||||
Run: `npx vitest run tests/unit/BackupService.test.ts -t runDaily`
|
||||
Expected: FAIL — `runDaily is not a function`
|
||||
|
||||
- [ ] **Step 3: Implement runDaily + lastSnapshotAt**
|
||||
|
||||
Replace `src/main/services/BackupService.ts` with this expanded version:
|
||||
|
||||
```typescript
|
||||
import type Database from 'better-sqlite3';
|
||||
import { mkdir, rename, stat, readdir, unlink, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { applyGfsRetention } from './backupRotation.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const MARKER_FILENAME = '.last-snapshot';
|
||||
|
||||
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 interface DailyResult {
|
||||
snapshotted: boolean;
|
||||
reason?: string;
|
||||
path?: string;
|
||||
bytes?: number;
|
||||
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 };
|
||||
}
|
||||
|
||||
async lastSnapshotAt(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(join(this.backupDir, MARKER_FILENAME), 'utf8');
|
||||
return raw.trim() || null;
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async runDaily(): Promise<DailyResult> {
|
||||
const today = toKstDateKey(this.now());
|
||||
const last = await this.lastSnapshotAt();
|
||||
if (last === today) {
|
||||
return { snapshotted: false, reason: 'already snapshotted today' };
|
||||
}
|
||||
const snap = await this.snapshot();
|
||||
await writeFile(join(this.backupDir, MARKER_FILENAME), today, 'utf8');
|
||||
const rot = await this.rotate();
|
||||
return {
|
||||
snapshotted: true,
|
||||
path: snap.path,
|
||||
bytes: snap.bytes,
|
||||
kept: rot.kept,
|
||||
removed: rot.removed
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run all BackupService tests, expect pass**
|
||||
|
||||
Run: `npx vitest run tests/unit/BackupService.test.ts`
|
||||
Expected: PASS — both snapshot + runDaily groups (≥ 13 tests).
|
||||
|
||||
- [ ] **Step 5: Run full test suite to verify no regressions**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS — original 52 + new tests (≈ 65+).
|
||||
|
||||
- [ ] **Step 6: Run typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS — 0 errors.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/services/BackupService.ts tests/unit/BackupService.test.ts
|
||||
git commit -m "feat(backup): runDaily() with .last-snapshot marker + rotate after snapshot
|
||||
|
||||
Skips when marker matches today's KST date. Marker written after
|
||||
successful snapshot, before rotation. lastSnapshotAt() exposed for UI."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire BackupService into main process
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/index.ts`
|
||||
- Modify: `src/main/tray.ts`
|
||||
|
||||
- [ ] **Step 1: Modify main/index.ts to instantiate BackupService**
|
||||
|
||||
In `src/main/index.ts`, add the import block alongside existing service imports (after `import { MediaGc }`):
|
||||
|
||||
```typescript
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
```
|
||||
|
||||
Inside `app.whenReady().then(async () => { ... })`, after `const gc = new MediaGc(db, store);` block, add:
|
||||
|
||||
```typescript
|
||||
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
|
||||
void backup.runDaily()
|
||||
.then((r) => logger.info('backup.daily', { ...r } as Record<string, unknown>))
|
||||
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
|
||||
```
|
||||
|
||||
(`join` is already imported at the top of the file from the autostart-init logic added in v0.2.0.)
|
||||
|
||||
- [ ] **Step 2: Add before-quit hook**
|
||||
|
||||
Inside `app.whenReady().then(...)`, **after** the `gc` and `backup` initialization, add:
|
||||
|
||||
```typescript
|
||||
let backupOnQuitDone = false;
|
||||
app.on('before-quit', (e) => {
|
||||
if (backupOnQuitDone) return;
|
||||
e.preventDefault();
|
||||
backup.runDaily()
|
||||
.then((r) => logger.info('backup.beforeQuit', { ...r } as Record<string, unknown>))
|
||||
.catch((e2) => logger.warn('backup.beforeQuit.failed', { reason: String(e2) }))
|
||||
.finally(() => {
|
||||
backupOnQuitDone = true;
|
||||
app.isQuitting = true;
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Then **remove** the existing top-level `app.on('before-quit', ...)` line at the bottom of the file:
|
||||
|
||||
```typescript
|
||||
// REMOVE THIS LINE:
|
||||
app.on('before-quit', () => { app.isQuitting = true; app.quit(); });
|
||||
```
|
||||
|
||||
(The new hook absorbs `app.isQuitting = true` setting and replaces the trivial one.)
|
||||
|
||||
- [ ] **Step 3: Pass runBackup callback to createTray**
|
||||
|
||||
In `src/main/index.ts`, modify the `createTray` invocation:
|
||||
|
||||
```typescript
|
||||
// BEFORE:
|
||||
// createTray(
|
||||
// () => createInboxWindow(),
|
||||
// () => showQuickCapture()
|
||||
// );
|
||||
|
||||
// AFTER:
|
||||
createTray(
|
||||
() => createInboxWindow(),
|
||||
() => showQuickCapture(),
|
||||
async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: r.snapshotted
|
||||
? `백업 완료 — ${r.removed?.length ?? 0}개 정리`
|
||||
: `오늘 백업이 이미 있습니다`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('backup.manual.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '백업을 만들지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Modify tray.ts to accept and use the new callback**
|
||||
|
||||
Replace `src/main/tray.ts` with:
|
||||
|
||||
```typescript
|
||||
import electron from 'electron';
|
||||
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
|
||||
const { app, Tray, Menu, nativeImage } = electron;
|
||||
|
||||
let tray: TrayType | null = null;
|
||||
|
||||
function buildMenu(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void
|
||||
) {
|
||||
const items: MenuItemConstructorOptions[] = [
|
||||
{ label: '구출한 메모 보기', click: showInbox },
|
||||
{ label: '기억 구출하기', click: showCapture },
|
||||
{ type: 'separator' },
|
||||
{ label: '지금 백업', click: runBackup }
|
||||
];
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
items.push({
|
||||
label: '윈도우 시작 시 자동 실행',
|
||||
type: 'checkbox',
|
||||
checked: openAtLogin,
|
||||
click: (item) => {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: item.checked,
|
||||
args: ['--hidden']
|
||||
});
|
||||
}
|
||||
});
|
||||
items.push({ type: 'separator' });
|
||||
} else {
|
||||
items.push({ type: 'separator' });
|
||||
}
|
||||
items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } });
|
||||
return Menu.buildFromTemplate(items);
|
||||
}
|
||||
|
||||
export function createTray(
|
||||
showInbox: () => void,
|
||||
showCapture: () => void,
|
||||
runBackup: () => void
|
||||
): TrayType {
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('Inkling');
|
||||
tray.setContextMenu(buildMenu(showInbox, showCapture, runBackup));
|
||||
tray.on('click', showInbox);
|
||||
return tray;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS — 0 errors. Verifies tray callback signature change is consistent.
|
||||
|
||||
- [ ] **Step 6: Run unit tests**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: PASS — 65+ tests, no regressions.
|
||||
|
||||
- [ ] **Step 7: Run e2e smoke**
|
||||
|
||||
Run: `npm run test:e2e`
|
||||
Expected: PASS — 1/1. Tray "지금 백업" entry doesn't break inbox empty-state assertion.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/index.ts src/main/tray.ts
|
||||
git commit -m "feat(backup): wire BackupService — whenReady + before-quit + tray
|
||||
|
||||
Instantiate BackupService at app.whenReady, run daily snapshot then
|
||||
again before quit (synchronous-blocking via preventDefault). Tray menu
|
||||
gets '지금 백업' entry that triggers manual runDaily with native
|
||||
toast feedback."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Promote F6-L1 in feedback collection
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-04-25-dogfood-feedback.md`
|
||||
- Create: `docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md` (extracted spec)
|
||||
|
||||
- [ ] **Step 1: Create extracted spec file**
|
||||
|
||||
Create `docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md`:
|
||||
|
||||
```markdown
|
||||
# F6-L1 Local Snapshot Spec (Promoted)
|
||||
|
||||
**Extracted from:** `2026-04-25-dogfood-feedback.md` F6 §"L1 — 로컬 원자 스냅샷"
|
||||
**Plan:** `docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md`
|
||||
**Status:** 🚀 promoted — implemented 2026-04-26
|
||||
|
||||
## 결정 (mini-brainstorm 결과)
|
||||
|
||||
| 결정 항목 | 값 | 근거 |
|
||||
|----------|-----|------|
|
||||
| 백업 위치 | `<profileDir>/backups/` | 프로필 단위 묶음, 코드 단순. 외부 디렉터리/사용자 지정 경로는 후속. |
|
||||
| 파일명 | `inkling-YYYY-MM-DD.sqlite` (KST 날짜) | 인간 가독 + 정렬 친화 |
|
||||
| 마커 | `<profileDir>/backups/.last-snapshot` (ISO 날짜 본문) | 같은 날 중복 백업 방지 |
|
||||
| 트리거 | `app.whenReady` 시 1회 + `before-quit` 1회 + 트레이 "지금 백업" | 슬라이스 §3 외부 dep 없이 만족 |
|
||||
| 보존 | 14 daily + 4 weekly (월요일) + 6 monthly (1일) | 사용자 결정 |
|
||||
| 원자성 | tmp + rename | 파셜 파일 방지 |
|
||||
|
||||
## 범위 (PR 안에 포함)
|
||||
|
||||
- `src/main/services/backupRotation.ts` (pure GFS function)
|
||||
- `src/main/services/BackupService.ts` (snapshot + rotate + runDaily + lastSnapshotAt)
|
||||
- `src/main/index.ts` 수정 (whenReady wiring + before-quit hook 합치기 + createTray 콜백 추가)
|
||||
- `src/main/tray.ts` 수정 (지금 백업 메뉴)
|
||||
- `tests/unit/backupRotation.test.ts`
|
||||
- `tests/unit/BackupService.test.ts`
|
||||
|
||||
## 후속 (별 spec 또는 후속 항목 후보)
|
||||
|
||||
- 외부 디렉터리 백업 (예: OneDrive 폴더 지정)
|
||||
- 미디어 포함 백업
|
||||
- 암호화 (SQLCipher / age)
|
||||
- 백업 무결성 검증 (PRAGMA integrity_check)
|
||||
- F6-L2 git sync 와 연결 (백업 디렉터리도 sync 대상에 포함할지)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update dogfood-feedback.md F6 status to promoted**
|
||||
|
||||
In `docs/superpowers/specs/2026-04-25-dogfood-feedback.md`, replace the F6 section header:
|
||||
|
||||
```markdown
|
||||
## F6. 메모 데이터 백업 + 복원 (3-layer 권장) (🌱 raw)
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```markdown
|
||||
## F6. 메모 데이터 백업 + 복원 (3-layer 권장) (🔬 drafting — L1 promoted)
|
||||
|
||||
**진행 상태:**
|
||||
- L1 (로컬 스냅샷) — 🚀 promoted → `docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md`
|
||||
- L2 (git sync) — 🌱 raw, 7번 항목으로 예정
|
||||
- L3 (import) — 🌱 raw, 3번 항목으로 예정 (F5 후)
|
||||
```
|
||||
|
||||
(Keep the rest of F6 content as-is — only the header line + a small status block change.)
|
||||
|
||||
- [ ] **Step 3: Run final verification**
|
||||
|
||||
Run: `npm run typecheck && npm test && npm run test:e2e`
|
||||
Expected: All green. Typecheck 0 errors, ≥ 65 unit tests pass, e2e 1/1.
|
||||
|
||||
- [ ] **Step 4: Commit promotion**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md
|
||||
git commit -m "docs(spec): promote F6-L1 local snapshot
|
||||
|
||||
Extracted to its own spec, dogfood-feedback.md F6 header reflects
|
||||
L1 promoted status while L2/L3 remain raw."
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Final commit — link plan to spec**
|
||||
|
||||
(Already done if the plan file exists at `docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md`. If not, create it from this document.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
**Spec coverage check:**
|
||||
|
||||
- ✅ BackupService + db.backup() wrapper — Tasks 2, 3
|
||||
- ✅ GFS rotation 14·4·6 — Task 1
|
||||
- ✅ 트레이 "지금 백업" — Task 4
|
||||
- ✅ before-quit hook — Task 4
|
||||
- ✅ `.last-snapshot` 마커 — Task 3
|
||||
- ✅ 단위 테스트 (로테이션) — Task 1
|
||||
- ✅ Out (외부 디렉터리·암호화·미디어 포함) — explicitly excluded, deferred to spec §"후속"
|
||||
- ✅ 슬라이스 §1.3 silent invariant ("데이터 손실 0회") — F6-L1 ships first satisfies this
|
||||
|
||||
**Type consistency check:**
|
||||
|
||||
- `SnapshotResult { path, bytes }` — used in Task 2 + Task 3 (runDaily returns `path`/`bytes` on success)
|
||||
- `RotateResult { kept, removed }` — used in Task 2 + Task 3 (runDaily returns `kept`/`removed` on success)
|
||||
- `DailyResult { snapshotted, reason?, path?, bytes?, kept?, removed? }` — Task 3 only
|
||||
- `applyGfsRetention(filenames, now): RetentionResult` — Task 1, called by `BackupService.rotate()` in Task 2
|
||||
- `parseBackupFilename(name): string | null` — Task 1, used internally by Task 1 only
|
||||
- `now: () => Date` injected — pattern matches `ContinuityService` constructor signature
|
||||
|
||||
**Risk:** `db.backup(tmpPath)` returns a Promise in better-sqlite3 12.x. Verified by `package.json` pinning to 12.9.0 and the @types/better-sqlite3 7.6.11 declaration includes `backup()`. If the API surface differs in this exact version, Task 2 Step 4 will fail loudly during test run, providing fast feedback.
|
||||
|
||||
**No placeholders. No TBD. No "implement later".** Every step has either a code block or an exact command.
|
||||
Reference in New Issue
Block a user