From 7973ea5046bb6b380345f43627a1ace1b838076c Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 26 Apr 2026 01:51:27 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20F6-L1=20local=20snapshot=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20(TDD,=205=20tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 위치: /backups/ (mini-brainstorm 결과 A 채택). 스키마 변경 0, 외부 dep 0. better-sqlite3.backup() API 가정. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-26-f6-l1-local-snapshot.md | 962 ++++++++++++++++++ 1 file changed, 962 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md diff --git a/docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md b/docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md new file mode 100644 index 0000000..36afbdc --- /dev/null +++ b/docs/superpowers/plans/2026-04-26-f6-l1-local-snapshot.md @@ -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 `/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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)) + .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)) + .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 결과) + +| 결정 항목 | 값 | 근거 | +|----------|-----|------| +| 백업 위치 | `/backups/` | 프로필 단위 묶음, 코드 단순. 외부 디렉터리/사용자 지정 경로는 후속. | +| 파일명 | `inkling-YYYY-MM-DD.sqlite` (KST 날짜) | 인간 가독 + 정렬 친화 | +| 마커 | `/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.