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