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:
altair823
2026-04-26 01:51:27 +09:00
parent 6d3df0273e
commit 7973ea5046

View 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.