F6-L1 local snapshot + roadmap prep (v0.2.1 dogfood-feedback Track #1) #2

Merged
altair823 merged 16 commits from feat/f6-l1-roadmap-prep into main 2026-04-26 01:31:56 +00:00
13 changed files with 5555 additions and 25 deletions

View File

@@ -55,6 +55,27 @@ Quick Capture 창이 화면 중앙 상단에 뜬다. 한 줄 던지고 `Ctrl+Ent
---
## 패키징 (Windows NSIS 인스톨러)
```bash
# Windows 개발자 모드 ON 필요 (winCodeSign 캐시 추출 시 darwin symlink 풀어야 해서)
# 설정 → 시스템 → 개발자용 → 개발자 모드 ON
npm run dist # NSIS 인스톨러: dist/Inkling Setup x.y.z.exe
npm run dist:dir # 패키징 없이 win-unpacked 디렉터리만
```
산출물:
- `dist/Inkling Setup 0.2.0.exe` — 약 100MB, oneClick=false (설치 위치 선택 가능)
- `dist/win-unpacked/` — portable 디렉터리, 그대로 실행 가능
설치 후:
- 첫 실행 시 `app.isPackaged === true``<프로필>/.autostart-init` 마커가 없을 때 한정 자동 시작 ON 으로 설정 (`--hidden` 인자 포함, inbox 창 안 뜨고 트레이만)
- 이후 트레이 메뉴 → "윈도우 시작 시 자동 실행" 토글로 조작
- 자동 시작 시 inbox 창은 안 뜸. `Ctrl+Shift+J` 또는 트레이 클릭으로 호출
---
## 테스트
```bash

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.

View File

@@ -159,6 +159,649 @@ H1 이 미달이면 본 항목 ❌ rejected.
---
## F3. "구출" 카피가 한국어로 어색함 (🌱 raw)
**발견:** 2026-04-26 dogfood 첫날, 메모 1건 캡처 후 OS 토스트 알림에서 "방금 하나의 업무 기억을 구출했습니다" 문구가 떴을 때.
### 관찰
`구출(rescue)` 은 한국어 일상어로 거의 쓰이지 않는다. 인질·재난 맥락에서 주로 쓰이는 어휘라서 "메모 한 줄 적었더니 구출됐다" 라는 표현이 어색하게 들린다. 영어 원문 의도(rescue a thought before you lose it) 를 직역한 결과로 추정.
현재 "구출" 등장 표면 (코드 경로 기준):
| # | 표면 | 위치 | 문구 |
|---|------|------|------|
| 1 | OS 토스트 (회전 카피 4종 중 3번째) | `src/main/services/NotificationService.ts:6` | `방금 하나의 업무 기억을 구출했습니다.` |
| 2 | 트레이 메뉴 | `src/main/tray.ts:13` | `기억 구출하기` |
| 3 | 트레이 메뉴 | `src/main/tray.ts:12` | `구출한 메모 보기` |
| 4 | Inbox 빈 상태 | `src/renderer/inbox/App.tsx:44` | `첫 기억을 구출해보세요.` |
| 5 | QuickCapture 힌트 | `src/renderer/quickcapture/App.tsx:68` | `Ctrl+Enter 구출 · Esc 취소 · 이미지 붙여넣기` |
추가 영향 표면 (코드 외):
- `package.json` `description: "Inkling — local-first 기억 구출 도구"`
- `tests/e2e/smoke.spec.ts:29` 의 단언 `await expect(inbox.getByText('첫 기억을 구출해보세요.')).toBeVisible()`
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` §5.5 의 카피 테이블 (519~523행)
- `docs/superpowers/strategy/strategy.md` 의 §1, §3 — "메모 작성 → 기억 구출" 으로 핵심 행동을 재정의한 **전략적 선언** 그 자체
### 제안 방향
**카피 변경 + strategy 문서의 어휘 결정 재검토를 한 항목으로 묶음.**
대체 후보 (전략 의도 보존도 vs 자연스러움 트레이드오프):
| 후보 | 의도 보존 | 자연스러움 | 비고 |
|------|----------|-----------|------|
| **꺼내 놓기** | 중 | 상 | "머릿속에서 꺼낸다" 직관, 기존 strategy "꺼내는 1회 행동" 표현과 일치 |
| **잡아두기** | 중 | 상 | "잊기 전에 잡아둔다" 어감, 가벼움 |
| **한 줄 던지기** | 상 | 상 | 슬라이스 §1.1 "3초 안에 던지고" 표현 그대로. 캡처 행위에 적합 |
| **남겨두기** | 하 | 상 | 평이함, 의도성 약함 |
| **적어두기/메모하기** | 하 | 상 | 전략 의도 상실 ("메모 작성" 회귀) |
| **챙겨두기** | 중 | 상 | 알림 카피에 자연 ("하나 챙겨뒀습니다") |
| **(현행) 구출** | 상 | 하 | 어색함 |
권장 1차안 — **표면별 다른 동사 허용** (한 단어로 통일 강제 안 함):
| 표면 | 권장 카피 |
|------|----------|
| 토스트 회전 카피 #3 | `방금 한 줄 잡아뒀습니다.` 또는 `머릿속에서 한 줄 꺼내뒀습니다.` |
| 트레이: 새 메모 | `한 줄 적기` 또는 `빠르게 한 줄` |
| 트레이: Inbox 열기 | `보관한 메모 보기` 또는 `Inkling 열기` |
| 빈 상태 | `첫 한 줄을 던져보세요.` 또는 `머릿속에 떠다니는 한 줄을 적어보세요.` |
| QC 힌트 | `Ctrl+Enter 저장 · Esc 취소 · 이미지 붙여넣기` |
핵심: "구출" 의 행동적 의미("정리·작성이 아니라 머릿속에서 꺼내기만") 는 보존하되, 직역체 한 단어로 강제하지 않고 표면별 자연 문맥에 맞춰 동사를 분배.
### 결정 대기
1. **Strategy 문서 §1, §3 의 "기억 구출" 선언을 함께 수정할 것인가?** — "구출" 은 단순 UI 카피가 아니라 전략 어휘다. 카피만 바꾸면 strategy 와 코드가 어긋난다. 같이 바꾸려면 strategy.md §1·§3·§7 갱신 + "꺼내기" / "한 줄" 같은 새 키워드 정의.
2. 단일 동사로 통일할 것인가, 표면별 자연어로 분배할 것인가? — 통일은 브랜딩 자산, 분배는 자연스러움.
3. 제품명 표어 ("local-first 기억 구출 도구") 는 어디까지 따라가는가? — `package.json description`, GitHub README 첫 문단, 향후 onboarding 문구 모두 영향.
4. e2e smoke 의 단언 문구 변경 시 카피 freeze 관행을 도입할지 — 카피 변경마다 e2e fix 가 따라오면 마찰 큼. 단언을 더 약하게 (`getByRole('main')` 등) 잡거나 카피 상수 import 가 대안.
5. 캡처-보상 카피 4종 중 #1, #2, #4 (`이 생각은 이제 Inkling이 들고 있습니다.` / `나중에 찾을 수 있게 보관했습니다.` / `기록 완료. 이제 잊어도 됩니다.`) 는 그대로 유지해도 자연스러우니 #3 만 교체로 충분한가? 아니면 전체 톤 재정렬?
### 가설·측정
| # | 가설 | 측정 |
|---|------|------|
| H1 | 본인 dogfood 1주 누적 시 "구출" 표현에 대한 위화감 토스트/메뉴 노출당 ≥ 1회 보고 | 일일 dogfood 로그의 카피 마찰 항목 |
| H2 | 권장안 적용 시 카피 위화감 보고 0건/주로 감소 | 동일 로그 |
| H3 | 외부인(가족·동료) 1명에게 권장안 vs 현행 블라인드 비교 시 권장안이 자연스럽다고 답함 | 정성 1회 |
### 범위
- **In:**
- `NotificationService.REWARD_COPIES`#3 교체
- 트레이 메뉴 라벨 2개
- Inbox 빈 상태 + QuickCapture 힌트
- `package.json description`
- smoke spec 단언 문구 동기화
- 카피 테이블 (`2026-04-24-inkling-vertical-slice-design.md` §5.5)
- **strategy.md §1·§3·§7 의 "기억 구출" 어휘 결정** 동시 갱신 여부 결정
- **Out:**
- 회전 카피 4종 전면 재작성 (필요하면 별 항목으로 분리)
- onboarding 흐름 (슬라이스 외)
- 로고·앱 아이콘 텍스트
- 다국어 (영어 카피)
### 영향
- **Schema:** 없음
- **코드:**
- `src/main/services/NotificationService.ts` — 회전 카피 한 줄
- `src/main/tray.ts` — 메뉴 라벨 2곳
- `src/renderer/inbox/App.tsx` — 빈 상태 문구
- `src/renderer/quickcapture/App.tsx` — 힌트
- `package.json` — description
- `tests/e2e/smoke.spec.ts` — 단언 문구 (또는 셀렉터 약화)
- **문서:**
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` 카피 테이블
- `docs/superpowers/strategy/strategy.md` — 결정 대기 #1 결과에 따라 부분 또는 전면
- 본 문서 — promoted 시 별 spec 으로 추출
### 메모
이 항목은 strategy 문서와의 어휘 일관성 결정이 함께 묶여 있어서, 단순 카피 PR 로 끝낼지 / strategy 재검토 미니 spec 으로 승격할지 결정 대기 #1 의 답이 promoted 경로를 좌우함. drafting 단계에서 결정 대기 #1 부터 답하고 ready-for-spec 으로 넘기는 순서 권장.
---
## F4. 떠오른 순간 → "Inkling!" 자동 연상 만들기 (🌱 raw)
**발견:** 2026-04-26 dogfood 시작 직전 사고 실험. 슬라이스 v0.4 와 strategy.md §3 가 **이미 알고 있는 contextual cue** (회의 후, 퇴근 전, 디버깅 후) 의 if-then 만 다루고, **ambient/spontaneous 떠오름** (샤워, 산책, 대화 중, 자기 전) 은 사각지대.
### 관찰
캡처 자체의 마찰은 거의 0 — `Ctrl+Shift+J` + 한 줄 + Enter = 3초. 하지만 dogfood 의 진짜 실패 모드는 **"3초가 너무 길다"** 가 아니라 **"그 3초가 머릿속에 안 떠오른다"** 다. 떠오름 → Inkling 연상 사이의 인지적 다리가 약하면, 메모 자체가 시도되지 않는다.
strategy.md 가 다루는 cue 는:
| 위치 | 내용 | 강한 cue 여부 |
|------|------|--------------|
| §3 회의형 | "회의가 끝나면" | ✅ 외부 신호 (Zoom 종료, 자리 일어남) |
| §3 퇴근 회고형 | "업무 종료 10분 전" | ✅ 시계·전등·동료 |
| §3 디버깅형 | "오류를 해결하면" | ✅ 테스트 PASS 화면 |
| §3 학습형 | "새로 배운 것이 있으면" | ⚠ 내부 인식, 약함 |
| §3 요청 관리형 | "누군가 업무를 요청하면" | ✅ 메시지/대화 |
남은 사각지대는 **외부 신호 없이 머릿속에서만 떠오르는 생각** — 샤워, 운전, 산책, 자기 직전, 책 읽다가, 잡담 중. 본인 dogfood 에서 가장 풍부한 메모 후보가 이 영역에서 발생할 가능성이 큰데 (RTX 4070 owner profile, 퇴근 후 사이드 프로젝트형 사고 패턴), 슬라이스가 cue 를 안 만들어주면 그냥 잊혀짐.
### 제안 방향
**6개 메커니즘 후보** — 각각 Inkling 의 좁은 실행 가능 단위로 변환.
#### A. 습관 쌓기 (Habit Stacking · BJ Fogg, James Clear)
기존 강한 자동 행동 직후에 새 행동을 붙임. "X 하면 Y 한다" — Y 가 X 의 부산물처럼 느껴지게.
| 강한 기존 자동 행동 | 붙일 Inkling 행동 |
|--------------------|-------------------|
| 노트북 덮음 / 잠금 (Win+L) | 30초 짧은 캡처 윈도우 자동 호출 |
| 휴대폰을 책상에 엎어놓음 | (휴대폰 앱이 생기면) 트리거 |
| 커피·물 가지러 일어남 | 타임랩스 카운트로 "자리 떴음 → 돌아왔을 때 한 줄" 프롬프트 |
| 메신저 알림 dismiss | 트레이 미니 입력 — Quick Capture 단축 |
슬라이스 가능: **잠금 화면 진입 직후 3초 캡처 윈도우**. Windows API `SystemEvents.SessionSwitch` (lock/unlock) 로 unlock 시 한 번만 가벼운 토스트 ("머릿속에 남은 한 줄?"). 토스트 클릭 = QC. 무시 = 사라짐.
#### B. 실행 의도의 확장 (Gollwitzer, ambient 영역)
§3 의 if-then 을 "내적 인식" 트리거로 확장.
```
"무언가 해야 한다는 생각이 들면 → 그 자리에서 Ctrl+Shift+J"
"어제 그거 어떻게 됐지? 라는 생각이 들면 → 그 자리에서 Ctrl+Shift+J"
"이거 까먹을 것 같다는 느낌이 들면 → 그 자리에서 Ctrl+Shift+J"
```
내적 cue 의 약점은 인식 자체가 안 일어난다는 점이지만, 1~2주 의식적 반복 후 자동화 가능 (Lally 18~254일 범위, 본 사례는 행동이 단순해서 짧은 쪽). 온보딩에서 사용자에게 **본인의 ambient 떠오름 패턴** 을 1개 직접 적게 하고 그걸 if-then 으로 변환.
#### C. 환경 앵커 (Cue Salience · 시각·물리적 단서)
생각이 떠오를 때 시야에 Inkling 이 있으면 연상 강화.
| 앵커 | 비용 | 슬라이스 적합도 |
|------|------|---------------|
| 잠금화면 배경에 Ctrl+Shift+J 안내 | 0 (사용자 셀프) | 외부 |
| 책상 모니터에 작은 펜 그림 스티커 | 0 (사용자 셀프) | 외부 |
| 작업 표시줄 트레이 아이콘 (이미 있음) | 0 | 슬라이스 ✅ |
| 트레이 아이콘 색을 "오늘 미캡처 시간" 에 따라 변화 | 작음 | 슬라이스 후속 |
| 윈도우 위젯 (Windows 11 위젯 보드) | 큼 | 슬라이스 외 |
가장 가벼운 즉시 적용: **트레이 아이콘에 색·뱃지** — 오늘 캡처 0건이면 점선 동그라미, 1건 이상이면 실선. 사용자가 무의식 중에 시야 끝에서 "비어있다" 신호를 받음.
#### D. 무작위 부드러운 알림 (Spaced retrieval · Variable interval)
랜덤 간격으로 "지금 머릿속에 떠다니는 한 줄?" 토스트. variable interval reinforcement 가 fixed interval 보다 행동 강화에 강하다는 행동주의 결과 응용. 단 **거슬리면 즉시 망함** — 회복 친화 톤 강제.
| 변수 | 권장 1차 |
|------|---------|
| 빈도 | 평일 09~18 KST 사이 2~3회, 분포는 90~180분 사이 무작위 |
| 작업 컨텍스트 가드 | full-screen 앱 감지 시 skip (회의·발표 중 보호) |
| 카피 톤 | "지금 한 줄 던져두면 좋을 게 있나요?" (강요 없음) |
| Off-toggle | 트레이 메뉴 1클릭 |
| 안티패턴 | 캡처 0/일 자체에 대한 죄책감 유발 카피 — 슬라이스 §1.1 위반 |
슬라이스 적합도: **중**. Hotkey + Tray 만으로도 충분하다는 §3 결정과 충돌 가능. dogfood 1주 후 본인 캡처 빈도 데이터 본 뒤 결정.
#### E. Zeigarnik 효과 활용 (미완 텐션 → 외재화)
미완 과제는 인지 자원을 점유하며 회상이 더 잘 된다는 Zeigarnik 1927. Inkling 의 "이제 잊어도 됩니다" 보상이 이미 이 방향이지만, **떠올림 → 캡처** 다리에서는 활용 안 됨. 강화 방향:
- 메인 토스트 카피 1개를 **사용 전 priming** 으로 재배치: 앱 첫 설치 직후 / 매일 첫 캡처 직후 짧게 "머릿속에 떠다니면 잡아두세요" 주입. 반복 노출이 ambient 떠오름 시 자동 reactivation.
- inbox 빈 상태 카피를 **유발 어휘** 로 변경: "오늘 머릿속에서 그냥 흘러간 생각 1개만 적어보세요" — 사용자에게 "흘러갔던 게 분명 있었지" 회상을 자극.
비용 0, 슬라이스 내 적용 가능. F3 (구출 카피 재검토) 와 자연스럽게 묶임.
#### F. 정체성 고리 (Identity-based habit · James Clear)
"메모 잘하는 사람이 되자" 가 아니라 "나는 머릿속을 비워두는 사람" 이라는 자기개념. 매 캡처마다 정체성 강화 카피 1개:
```
"오늘 7번째 비웠습니다." (count visible)
"한 주 누적 23개. 잊을 자유 23번."
"이번 주 다시 본 메모 4건 — 외부 기억이 일하고 있음."
```
이미 §4.2 능력감 보상에 일부 있음. 차이는 "기억력" 이 아니라 "비움/외재화" 정체성으로 frame. 트레이 메뉴 또는 inbox 헤더에 작은 카운터.
### 결정 대기
1. **슬라이스 외부 vs 후속 미니 spec**: A·D 는 새 IPC + Windows API + 알림 스케줄링이 필요해 슬라이스 §3 ("Hotkey + Tray + Notification 만") 와 충돌. C·E·F 는 슬라이스 내 적용 가능. 어디까지 슬라이스에서 시도하고 어디서부터 별 spec 으로 분기?
2. **무작위 알림 (D) 의 거슬림 임계점**: 본인 dogfood 1주 누적 데이터 (캡처 빈도 중간값) 를 보기 전에 결정 미루기. 데이터 없이 도입하면 anti-pattern 될 위험.
3. **메커니즘 결합 vs 격리 시험**: 6개 동시 적용 시 어느 게 효과 있었는지 분리 불가. dogfood 단계에서 격리 A/B 가능한가? 본인 단일 사용자라 통계 의미 약함 — 정성 라벨링으로 대체.
4. **strategy.md 와의 관계**: 본 항목이 strategy.md §3 ambient cue 절을 신규 §3.6 으로 추가할 만큼 무거운가, 아니면 specs/ 만으로 충분한가? F3 와 동일한 "strategy 동반 갱신 여부" 결정 패턴.
5. **외부 의존 (Win API) 도입의 슬라이스 의의**: A 의 SessionSwitch hook 은 슬라이스에서 한 번도 열어본 적 없는 native API. 도입 시 §7 dependency invariant 에 추가, 그 비용 vs cue 효과 가치 비교 필요.
### 가설·측정
| # | 가설 | 측정 |
|---|------|------|
| H1 | E (Zeigarnik 카피 재배치) 만 적용해도 본인 dogfood 1주 캡처 빈도 ≥ 30% 증가 | 캡처/일 카운트, 적용 전후 1주씩 |
| H2 | C (트레이 뱃지 0/N 표시) 가 시각적 cue 로 작동하여 12~18시 사이 "지금 비어있네" 자각 사건 ≥ 2회/주 | 본인 라벨링 |
| H3 | D (무작위 알림) 가 캡처 빈도를 늘리지만 거슬림 점수 (1~5) 가 평균 ≤ 2 | 정성 점수 |
| H4 | F (정체성 카피) 가 dogfood 종료 시 "Inkling 을 계속 쓰고 싶다" 점수에 양의 영향 | exit 인터뷰 |
| H5 | B (ambient if-then 온보딩) 의 사용자 작성 if-then 1~2개가 1주 후에도 회상 가능 | 본인 self-report |
### 범위
- **In (슬라이스 가능 — 가벼운 적용):**
- C 의 일부 — 트레이 아이콘 색·뱃지 (오늘 캡처 0/≥1)
- E — Zeigarnik priming 카피 1줄 + 빈 상태 카피 재작성 (F3 와 묶기)
- F — 트레이/Inbox 헤더의 정체성 카피 1줄 (count 표시)
- **In (슬라이스 후속 미니 spec):**
- A — 잠금/잠금해제 시 부드러운 캡처 프롬프트
- B — 온보딩에 ambient if-then 1~2개 작성 단계 추가
- D — 무작위 알림 + 가드 (full-screen, off-toggle)
- **Out:**
- 음성 ("Hey Inkling")
- 휴대폰 앱
- 위젯 보드 통합
- 외부 캘린더 cue 연동
### 영향
- **Schema:** 없음 (모두 클라이언트 사이드 또는 카피)
- **코드:**
- **C 트레이 뱃지:** `tray.ts` — 이미지 동적 갱신, `repo.countToday(): number` 또는 `getInbox` 필터로 오늘 카운트 조회. 10분 간격 또는 새 노트 생성 IPC 신호로 갱신.
- **E priming 카피:** `NotificationService.REWARD_COPIES`, `inbox/App.tsx` 빈 상태, `quickcapture/App.tsx` 힌트
- **F 정체성 카피:** `inbox/App.tsx` 헤더 영역 (count + 한 줄), 또는 트레이 tooltip
- **A 후속:** `electron.powerMonitor``lock-screen`/`unlock-screen` 이벤트, 새 `LockHookService`
- **D 후속:** 새 `RandomPromptService` — setInterval + jitter, full-screen 감지는 `screen.getDisplayNearestPoint` 또는 native 호출
- **문서:**
- 본 항목 promoted 시 `2026-04-26-cue-strengthening.md` (가칭) 으로 추출
- strategy.md 결정 #4 결과에 따라 §3.6 신설 또는 본 spec 만으로 종결
- **테스트:**
- C 의 단위 테스트 — 카운트 0/N 분기, 날짜 경계 KST
- E·F 카피 변경에 따른 e2e smoke 단언 동기화 (F3 와 묶음)
### 비고
본 항목은 슬라이스 §1.3 의 종료 조건 ("본인 2주 dogfood 완주") 을 **달성하기 위한 메타-행동 설계**다. 즉 slice 의 "기능 엣지" 가 아니라 "사용자가 slice 를 안 잊고 쓰게 하는 메타 레이어". 우선순위는 H1 (E 만 1주 시도) → 데이터 확인 → C·F 추가 → A·D 검토 순서 권장. 6개 메커니즘 동시 도입은 신호 분리 불가로 안티패턴.
---
## F5. 마크다운 일괄 export (RAG 활용 가정) (🌱 raw)
**발견:** 2026-04-26 dogfood 시작 직전 사고 실험. 슬라이스 v0.4 는 노트가 SQLite + 로컬 미디어 폴더에만 존재. 외부 도구 (Obsidian, RAG 파이프라인, 로컬 LLM 컨텍스트, 검색 엔진) 로 빼낼 통로가 0.
### 관찰
스키마에 이미 export 에 필요한 모든 정보가 있다 (`m001_initial.ts:7~57`):
- `notes` — id, raw_text, ai_{title,summary}, *_edited_by_user, user_intent, intent_prompted_at, created_at, updated_at, ai_provider, ai_generated_at
- `note_tags` (+`tags`) — 태그 + source(ai/user)
- `media` — note 첨부 이미지 메타 (`rel_path` 가 MediaStore 의 프로필 디렉터리 기준)
- (F1 promoted 시) `due_date`, `due_date_edited_by_user`
내보낼 자산은 두 종류:
1. **노트 본문 + 메타데이터** — 텍스트
2. **첨부 이미지** — 바이너리 파일 (현재는 `<profile>/media/<rel_path>`)
RAG 파이프라인 (LangChain, LlamaIndex, ChromaDB, 로컬 embedding) 의 표준 입력은 **YAML frontmatter 가 붙은 단일-노트-단일-파일 마크다운** + 안정 ID + 카테고리/태그 메타. 본 export 는 이 형식에 정렬해야 후속 의사결정 (RAG 도입, Confluence 동기화, Obsidian vault 사용) 모두 한 형식으로 흡수 가능.
### 제안 방향
**1차 권장 — 디렉터리 트리 + frontmatter 마크다운 + index.jsonl + 미디어 동봉.**
```
inkling-export-2026-04-26/
notes/
2026-04-25-014a3b9c-주간회고-PR-리뷰.md
2026-04-25-02f17de8-새-디버깅-패턴.md
...
media/
014a3b9c__1.png # MediaStore rel_path 평탄화
02f17de8__1.jpg
index.jsonl # RAG 친화 1줄=1노트 메타
manifest.json # 스키마 버전, 내보낸 시각, 노트 수, 검증 해시
README.md # 형식 설명, RAG 적용 가이드
```
**노트 파일 포맷** (one file per note, RAG 친화):
```markdown
---
id: 014a3b9c-...
created_at: 2026-04-25T14:23:11+09:00
updated_at: 2026-04-25T14:24:02+09:00
title: 주간 회고 PR 리뷰
title_source: ai # ai | user (edited 면 user)
summary: 회고 양식 통일을 위한 PR 리뷰 메모.
summary_source: ai
tags:
- { name: pr, source: ai }
- { name: review, source: user }
user_intent: 팀에서 회고 양식 통일
intent_prompted_at: 2026-04-25T14:24:02+09:00
due_date: 2026-05-01 # F1 promoted 시
due_date_source: ai
ai_provider: local-ollama/gemma4:e4b
ai_generated_at: 2026-04-25T14:23:34+09:00
images:
- rel: media/014a3b9c__1.png
mime: image/png
bytes: 152834
inkling_export_version: 1
---
# 주간 회고 PR 리뷰
> 회고 양식 통일을 위한 PR 리뷰 메모.
내일 까지 PR 리뷰 마무리하고, 회고 양식은 팀에 공유.
오후 미팅 중에 떠올랐음.
![](media/014a3b9c__1.png)
```
**index.jsonl** (RAG 인덱싱용 1줄=1노트):
```json
{"id":"014a3b9c-...","path":"notes/2026-04-25-014a3b9c-주간회고-PR-리뷰.md","created_at":"2026-04-25T14:23:11+09:00","tags":["pr","review"],"due_date":"2026-05-01","embedding_text":"주간 회고 PR 리뷰\n\n내일 까지 PR 리뷰 마무리하고..."}
```
`embedding_text` 는 title + raw_text + tags 를 결합한 임베딩 입력 후보. 사용자가 별도 가공 없이 LangChain `JSONLoader` 또는 LlamaIndex `JSONReader` 로 바로 적재 가능.
**파일명 컨벤션:** `YYYY-MM-DD-{id8}-{slugified-title}.md`. 충돌 회피 + 인간 가독 + 디렉터리 정렬 친화.
- `id8` = UUIDv7 의 처음 8자리. 시간 정렬 + 충돌 0.
- `slugified-title` = title 의 한글 보존, 공백→하이픈, 파일시스템 금지 문자 제거 (`/\\:*?"<>|`), 32자 제한.
- title 비어있으면 `untitled` 폴백.
**트리거 (1차 권장):** 트레이 메뉴 → "마크다운으로 내보내기..." → Electron `dialog.showOpenDialog({ properties: ['openDirectory'] })` 로 사용자가 폴더 선택 → 진행 토스트 → 완료 토스트 (성공 시 노트 수 + "폴더 열기" 버튼).
**증분 vs 전체:** 1차는 전체 덮어쓰기만. 증분(변경된 노트만 갱신)은 후속.
### 결정 대기
1. **포맷 1차안 확정**: one-file-per-note + frontmatter + index.jsonl 트리플 vs 단일 monolithic .md vs 두 형식 동시 출력? → RAG 우선이면 트리플이 압도적이지만 사용자 선호 확인 필요.
2. **미디어 포함 기본값**: 항상 동봉 vs 사용자 선택 (체크박스). 슬라이스 §1.1 의 "raw_text 본문에 민감정보 가능" 정책 — 이미지가 스크린샷인 경우 export 가 의도치 않은 노출 통로가 될 수 있음. **기본 동봉 + export 시 다이얼로그에 "이미지 N개 포함됩니다" 명시** 가 안전.
3. **삭제된 노트 처리**: SQLite 에 soft-delete 컬럼이 없음. 현재는 hard delete. export 결과는 *현 시점* 스냅샷만 — 삭제 이력 없음. 충분한가, 별 issue 인가?
4. **필드 정책 — provenance 표현**: `title_source: ai|user` 같은 단일 enum vs `title: { value, source, edited_at }` 객체. RAG 파이프라인의 frontmatter parser 마다 다름 — 평탄한 enum 이 호환성 좋음.
5. **embedding_text 합성 규칙**: title + raw_text + tags 단순 결합 vs raw_text 만 (가장 untouched) vs title + summary + raw_text. 본인 RAG 사용 패턴 미정 — 1차는 raw_text 단독으로 시작 + 옵션화.
6. **파일명에서 raw_text vs ai_title 사용**: ai_title 사용이 가독성 좋지만 AI 변경 시 파일명도 변하는 안티패턴. **ai_title 사용 + 사용자가 수동 export 트리거 시점 기준** 으로 동결 (재 export 시 새 파일명). 파일명 안정성 vs 가독성 트레이드오프 명시.
7. **트리거 표면**: 트레이 메뉴만 vs Inbox 헤더 버튼도 추가 vs CLI 플래그 (`--export <dir>`). 자동화 사용자라면 CLI 가 매력. 슬라이스 후속 미니 spec 으로 분리 가능.
8. **export 형식 버전 정책**: `inkling_export_version: 1` 박아두고 후속 변경 시 마이그레이션 가이드 동봉. 처음부터 박는 게 깔끔.
9. **민감정보 표시 경고**: 본 사용자는 `dlsrks0734@gmail.com` 계정 본인 단일 사용자라 위험 낮지만, export 후 폴더가 어디 가는지에 따라 위험 발생. 트레이 export 다이얼로그에 "이 export 는 평문이며 raw_text 가 그대로 포함됩니다" 명시 필요.
### 가설·측정
| # | 가설 | 측정 |
|---|------|------|
| H1 | dogfood 1주 후 본인이 export 한 마크다운 더미를 LlamaIndex 기본 markdown loader 로 직접 적재 가능 (사이즈 변환 0) | 실측 — 1회 시도 |
| H2 | export 결과의 frontmatter 가 Obsidian 의 frontmatter renderer 에도 호환 | 실측 — Obsidian 에 폴더 import 후 메타 표시 확인 |
| H3 | 노트당 평균 raw_text 길이 ≤ 200 토큰 → RAG chunking 불필요 | 표본 50건 토크나이저 통계 |
| H4 | export 누적 사이즈가 1MB / 100 노트 이하 (미디어 제외) | 측정 |
| H5 | 본인이 export → 외부 도구 적재 → 적어도 1번 의미 있는 회수 (검색·RAG·재방문) 발생, dogfood 2주 내 | 본인 라벨링 |
### 범위
- **In:**
- `ExportService` 신규 — DB 쿼리 + 파일 쓰기 + 미디어 복사
- 트레이 메뉴 항목 1개 추가 ("마크다운으로 내보내기...")
- Electron `dialog` 디렉터리 선택
- frontmatter 합성 + 파일명 슬러그
- `index.jsonl` + `manifest.json` + `README.md` 동시 생성
- 미디어 평탄화 복사 (rel_path → `media/{id8}__{n}.{ext}`)
- 진행 상태 토스트 (노트 수 ≥ 100 시 진행률)
- 단위 테스트 — frontmatter 합성, 슬러그, JSON 직렬화
- **Out (후속 미니 spec):**
- 증분 export
- 자동 export (cron / watch)
- CLI 플래그
- import (역방향)
- 다중 형식 (CSV, JSON 단일 파일, OPML)
- 외부 SaaS 동기화 (Confluence, Notion)
- export 시 raw_text 마스킹·익명화
### 영향
- **Schema:** 없음 — 현 스키마로 충분
- **신규 파일:**
- `src/main/services/ExportService.ts`
- `src/main/ipc/exportApi.ts`
- 테스트 `tests/unit/ExportService.spec.ts`
- **변경 파일:**
- `src/main/index.ts` — 등록
- `src/main/tray.ts` — 메뉴 항목 추가
- `src/preload/index.ts` — IPC expose
- **외부 의존:**
- 없음 — Node `fs/promises` + `path` + `node:crypto` (해시) 만 사용
- YAML 직렬화는 frontmatter 가 단순 하므로 자체 구현 (외부 dep 추가 불필요)
- **로깅:**
- export 시작·완료·노트 수만 기록. raw_text·title·summary 미기록 (slice §1.1 invariant 4 그대로)
- **문서:**
- 본 항목 promoted 시 `2026-04-26-markdown-export.md` (가칭) 으로 추출
- 추출 후 README 의 doc map 갱신
- export 폴더 안의 `README.md` — RAG 적재 예시 코드 포함
### 비고
본 항목은 **읽기 전용** 이라 dogfood 안전성 영향 0 (raw_text 변경 없음, AI 호출 없음, 네트워크 0). 우선순위 측면에선 F1·F2·F3 보다 후순위지만 **F4 의 H5 (외부 도구로 회수) 평가 자체가 export 없이는 측정 불가** — 즉 F4-H5 = F5 dependency. F4 의 데이터 수집을 위해 F5 가 먼저 promoted 되는 경로도 있음.
---
## 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 후)
**발견:** 2026-04-26 dogfood 시작 직전 사고 실험. 슬라이스 v0.4 의 메모 데이터는 `%APPDATA%\Inkling\Inkling\profiles\default\` 단 한 위치에만 존재. 디스크 고장·실수 삭제·DB 손상·OS 재설치 = 총 손실. Strategy.md §1 의 "이제 잊어도 됩니다" 보상이 **데이터 영속성 신뢰** 위에 서 있어서, 이 신뢰가 깨지면 슬라이스 §1.3 의 종료 조건 ("본인 2주 dogfood 완주") 자체가 위협받음.
### 관찰
현재 단일 실패 지점 (SPOF):
- `inkling.sqlite` (WAL 두 파일 포함) — 노트·태그·AI 메타·intent 전부
- `media/` — 클립보드 이미지 바이너리 (DB 의 `rel_path` 와 짝)
- 부팅 시 `MediaGc` 가 DB 미참조 미디어를 정리 — DB 가 손상되면 미디어도 GC 사이클에서 사라질 수 있음 (위험 증폭)
기존 부분 완화는 0:
- 자동 백업 0
- 외부 동기화 0
- import 경로 0
- F5 (export) 가 promoted 되어도 단방향 + 수동
본인 dogfood 운영 환경 신호:
- 이미 `gitea.altair823.xyz` 자체 호스팅 중 — 사적 git remote 인프라가 있음
- 프로젝트 메모리: Mac=업무 / Windows=개인+dogfood — 디바이스 전환 가능성 (단일 활성, 동시 X)
- RTX 4070 Windows = 메인 dogfood 머신, 디스크 1대 SSD 가정 → 디스크 고장 1회 = 전체 손실
### 제안 방향
**3-layer 다층 백업.** 각 layer 가 다른 위협 모델을 커버.
| Layer | 위협 모델 | 비용 | 슬라이스 적합 |
|-------|----------|------|--------------|
| **L1 로컬 원자 스냅샷** | 실수 삭제, DB 손상, AI 마이그레이션 실패 | 작음 | ✅ 슬라이스 후속 가벼움 |
| **L2 git remote 마크다운 동기화** | 디스크 고장, 디바이스 이동, 버전 이력 필요 | 중 | 🔬 별도 미니 spec |
| **L3 전체 export/import** | OS 재설치, 디바이스 이주, 사용자 통제 백업 | 작음 (L1 + F5 위) | ✅ F5 위에 import 만 추가 |
#### L1 — 로컬 원자 스냅샷
`better-sqlite3``db.backup(path)` API 사용. WAL 활성 상태에서도 안전한 원자적 복제 (파일 단순 cp 와 다름 — WAL 미반영분 누락 위험 없음).
```ts
// 의사코드
async function snapshot(): Promise<void> {
const ts = format(new Date(), 'yyyy-MM-dd');
const dest = join(profileDir, 'backups', `inkling-${ts}.sqlite`);
await db.backup(dest);
await rotate({ daily: 14, weekly: 4, monthly: 6 });
}
```
**스케줄**:
- 앱 종료 직전 1회 (`before-quit`)
- 매일 첫 캡처 시 (`<profileDir>/backups/.last-snapshot` mtime 비교)
- 명시 트리거: 트레이 메뉴 "지금 백업"
**저장 정책 — Grandfather-Father-Son**:
- 일일 14개 → 주간 4개 → 월간 6개. 누적 24개 안팎, 평균 사이즈 가정 시 < 50MB.
- `backups/` 는 미디어 미포함 (DB 만). 미디어는 L2 또는 L3 책임.
**위협 미커버**: 디스크 자체 고장. SSD 가 죽으면 backups/ 도 같이 죽음. L2 가 이 위협 담당.
#### L2 — git remote 동기화 (RECOMMENDED 핵심 layer)
**핵심 결정: SQLite 바이너리를 push 하지 말고, F5 마크다운 트리를 push 한다.**
| | SQLite 바이너리 | F5 마크다운 트리 |
|--|----------------|-----------------|
| diff 의미성 | 0 (전체 blob 변경) | ✅ 노트별 라인 diff |
| repo 사이즈 | 매 push 마다 풀 DB | 변경 노트만 |
| 멀티 디바이스 머지 | 불가 (binary conflict) | 가능 (텍스트 merge) |
| 외부 도구 호환 | 0 | RAG / Obsidian / grep 즉시 |
| F5 와 의 시너지 | 0 | F5 그대로 재사용 |
**F5 의 export 형식을 git 추적 대상으로 그대로 사용**. F5 가 promoted 되면 F6-L2 는 그 위에 자동화 layer 만 얹는 구조.
**아키텍처**:
```
[CaptureService / NoteRepository]
│ (write)
inkling.sqlite ← Layer 0 (primary)
│ (DB write 후 dirty 마크)
<profileDir>/sync/ ← Git working tree (L2)
├── notes/ ← F5 형식 마크다운
├── media/
├── index.jsonl
└── manifest.json
▼ (BackgroundSyncWorker, 5분 주기 또는 dirty=true 후 30초 debounce)
git add . && git commit -m "..." && git push
```
**커밋 메시지 컨벤션** (자동 생성):
```
chore(notes): +3 ~1 -0 (2026-04-26T14:23+09:00)
added: 01H89aab... 주간 회고 PR 리뷰
added: 01H89bcd... ...
modified: 01H78xyz... 어제 회의 메모
```
기존 inkling 본 저장소 commit 스타일과 분리되며, "automated note sync" 임이 명확.
**Auth & 보안**:
- Personal Access Token 또는 SSH key. Electron `safeStorage` API (OS keychain 백엔드 — Windows 는 DPAPI) 로 평문 미저장.
- 토큰은 절대 로그/오류 메시지에 노출 금지 (slice §1.1 invariant 4 확장).
- repo 는 **반드시 private** — 평문 raw_text 노출 위험. 처음 설정 시 다이얼로그에 굵은 경고.
**Conflict 정책 — single-active-device 가정**:
- push 가 거부되면 (다른 디바이스가 먼저 push) → `git pull --rebase` → 자동 머지 시도
- 머지 실패 (같은 노트 양쪽 수정) → 트레이 알림 + 수동 해결 다이얼로그. 노트별 "내 버전 / 원격 버전 / 둘 다 보존" 3-way 선택
- 본인 dogfood = 단일 활성 디바이스라 거의 발생 안 함 — 멀티 디바이스 시나리오 정식 지원은 L2 의 v2
**Repo 초기화**:
- 첫 설정 시 사용자가 빈 remote URL 입력 → 앱이 `git init` + 초기 export + 첫 커밋 + push
- 또는 기존 repo URL 입력 → clone → 검증 (이전 manifest 호환성) → 동기화 시작
**미디어 정책**:
- 평문 push 가 default — 텍스트 노트와 함께 미디어도 git 에 올라감
- repo 사이즈 폭발 위험 → 토큰 옵션: "이미지 제외" 토글 또는 Git LFS (선택). 1차는 옵션 X, 단순 push, 사이즈 모니터링만.
- 이미지 제외 시 frontmatter 의 `images` 항목은 보존하되 파일은 미포함 → 복원 시 placeholder 표시
#### L3 — 수동 전체 export / import
- **export**: F5 가 그대로 담당. 변경 없음.
- **import**: 신규. F5 형식 폴더를 읽고 DB 에 upsert. 충돌 정책:
- id 충돌 + 본문 동일 → skip
- id 충돌 + 본문 상이 → 사용자 선택 (덮어쓰기 / skip / 양쪽 보존하며 새 id 생성)
- id 신규 → insert
- 미디어 → MediaStore 에 복사
- 트레이 메뉴 "백업에서 복원..." → 폴더 선택 → 미리보기 (n개 신규, m개 변경, k개 충돌) → 확인 → 적용
### 결정 대기
1. **3-layer 동시 도입 vs 단계적**: L1 → L3 → L2 순서가 비용·위험 단조 증가라 권장. L1 만으로도 SPOF 완화의 80% 커버.
2. **L2 sync 단위**: 매 변경 vs 5분 debounce vs 종료 시 1회 vs 명시 동기화만. 실시간일수록 데이터 손실 윈도우 작지만 git push 빈도 폭발 + 네트워크 마찰. **5분 debounce + 종료 시 즉시 push** 가 1차 권장.
3. **L2 repo 분리**: 기존 `gitea.altair823.xyz/altair823-org/inkling` (소스 코드) 와 분리된 별 repo (예: `altair823-org/inkling-data`) — **반드시 분리**. 데이터·코드 라이프사이클 다름, 외부 협업자에게 데이터 노출 위험.
4. **L2 충돌 시 정책 — slice §1.1 vs 사용자 선택**: 자동 "내 디바이스 우선" 가속 vs 매번 묻기. dogfood 단일 디바이스 가정으론 자동 OK, but defensive 차원에서 충돌 발생 시 1회 확인이 안전.
5. **media 의 git 추적**: 포함 vs 제외 vs LFS. 1차는 포함 + 사이즈 < 100MB 경고. 누적 시점에 후속 결정.
6. **L1 백업 위치**: `<profileDir>/backups/` (현 프로필 안) vs 별 디렉터리 (`%APPDATA%\Inkling\backups\`) vs 사용자 지정 외부 경로. 외부 경로 옵션이 OneDrive 등 클라우드 sync 폴더 이용 가능 — 거의 공짜 cloud backup.
7. **import 시 raw_text invariant 보호**: slice §1.1 "raw_text 불변" 은 *동일 id 내* 의미. import 가 같은 id 의 raw_text 를 다른 값으로 덮어쓰면 invariant 위반. 충돌 시 raw_text 다르면 **새 id 강제** 정책이 안전.
8. **L2 첫 설정의 UX 부담**: token 입력 + remote 검증 + 초기 push 가 dogfood 1일차 첫 인상에 마찰. 첫 설치 후 N 일 (예: 7일) 까지는 L1 만 켜두고 L2 는 트레이 메뉴 "원격 백업 설정" 으로 opt-in 권장.
9. **암호화 — local-first 라도 token 외 추가 보호 필요한가**: SQLite·미디어·git 모두 평문. 디스크 도난 시 노출. 1차는 평문 (slice §1.1 미적용 영역), 후속에 SQLCipher / age 암호화 검토.
10. **slice §7 strict-pin invariant 영향**: L1 은 `better-sqlite3.backup()` 만 사용 — 추가 dep 0. L2 는 `simple-git` 또는 `nodegit` 같은 git 바인딩 또는 child_process 로 git CLI 호출. CLI 호출이 dep 0 + 사용자 git 환경 재사용. **CLI 호출 권장**.
### 가설·측정
| # | 가설 | 측정 |
|---|------|------|
| H1 | dogfood 2주 누적 동안 디스크 측 사건 (실수 삭제, DB 손상, 디스크 고장) ≥ 1회 발생할 정성 가능성 | 발생 시 라벨링 |
| H2 | L1 단독 만 도입해도 SPOF 발생 시 회복 가능 (백업으로 ≥ 95% 데이터 복원) | 복원 시뮬레이션 1회 (의도적 DB 삭제 후 복원) |
| H3 | L2 5분 debounce push 가 일평균 ≤ 30 commit. repo 사이즈 누적 < 100MB / 1년 | 로그 측정 |
| H4 | L2 commit 메시지 통계 (added·modified·deleted) 가 dogfood 활동 회고 자료로 가치 발생 | 정성 평가 |
| H5 | "이제 잊어도 됩니다" 보상의 신뢰도 — 백업이 있다는 인지가 capture 빈도 또는 심리적 부담 감소에 영향 | 본인 self-report |
### 범위
- **In (L1 — 슬라이스 후속 가벼움):**
- `BackupService` 신규 — `db.backup()` 래핑 + 로테이션
- 트레이 메뉴 "지금 백업" + 타임스탬프 표시
- 종료·일일 1회 자동 트리거
- `backups/` 디렉터리 — `.gitignore` 와 같은 .ignored 마커 고려
- 단위 테스트 — 로테이션 GFS 정책
- **In (L3 — F5 위에 import 만):**
- `ImportService` 신규
- 충돌 미리보기 다이얼로그
- 트레이 메뉴 "백업에서 복원..."
- **In (L2 — 별 spec, 가장 큼):**
- `SyncService` (BackgroundSyncWorker)
- F5 ExportService 의 incremental 모드 (변경 노트만)
- git CLI 래퍼 + safeStorage 토큰 관리
- 설정 UI — remote URL, 토큰, 동기화 주기, 미디어 포함 여부, 충돌 정책
- 충돌 해결 다이얼로그
- 상태 표시 (트레이 아이콘 색·tooltip)
- **Out:**
- SQLCipher 암호화
- 다중 활성 디바이스 실시간 sync
- 외부 SaaS (Dropbox API, Google Drive API) 직접 연동
- Rsync 전송
- SQLite WAL 의 logical replication
### 영향
- **Schema:** 없음
- **신규 파일 (L1 + L3):**
- `src/main/services/BackupService.ts`
- `src/main/services/ImportService.ts`
- `src/main/ipc/backupApi.ts`
- 테스트 `tests/unit/BackupService.spec.ts`, `ImportService.spec.ts`
- **신규 파일 (L2 별 spec):**
- `src/main/services/SyncService.ts`
- `src/main/services/GitClient.ts` (git CLI 래퍼)
- `src/main/services/CredentialStore.ts` (safeStorage 래퍼)
- 설정 UI (Settings 창 신설 — 슬라이스 §5 의 "Settings 창 없음" 결정 재검토 필요)
- **외부 의존:**
- L1: 0
- L3: 0
- L2: 사용자 머신의 git CLI 필요. README 사전 요구 항목 추가
- **로깅:**
- 백업 시작·완료·사이즈만. 본문·파일명 미기록
- 동기화 push 결과·conflict 발생만. 토큰·URL 일부 마스킹
- **문서:**
- 본 항목 promoted 시 분리 권장:
- `2026-04-26-local-snapshot.md` (L1)
- `2026-04-26-import.md` (L3, F5 와 자매)
- `2026-04-26-git-sync.md` (L2)
- 또는 단일 `2026-04-26-backup-strategy.md` 로 통합 후 §A·§B·§C 로 분리
### 비고
본 항목과 F5 는 **완벽한 데이터 라이프사이클 그림** 의 두 절반:
- F5 = 외부 회수 (read 방향)
- F6 = 외부 백업 + 내부 복원 (write·sync 방향)
L2 (git sync) 가 dogfood 본인의 기존 인프라 (gitea 자체 호스팅) 와 자연스럽게 맞물리는 점은 본 사용자에게 특히 강한 가치. 다른 사용자였다면 GitHub Actions 등 외부 서비스 의존이라 우선순위 낮을 수 있음.
slice §1.3 종료 조건 ("크래시 0회") 와 별개로, **"데이터 손실 0회"** 가 silent invariant 로 추가되어야 함. 본 항목 → `slice spec §1.3` 추가 갱신 후보.
---
## (다음 항목 자리)
새 피드백 추가 시 `## F3. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.
새 피드백 추가 시 `## F7. 짧은 제목 (🌱 raw)` 헤더로 시작. 표준 슬롯 6개 채우거나 비워둔 채 시작 가능.

View File

@@ -0,0 +1,40 @@
# 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 (월요일, anchor + 4 prior = 최대 5 Mondays) + 6 monthly (1일) | 사용자 결정. plan 산문은 "4 weekly" 였으나 plan 테스트 케이스가 5 Mondays 를 요구 → 테스트 우선 채택. |
| 원자성 | tmp + rename + 실패 시 tmp 정리 | 파셜 파일 방지, 코드 review I1 반영 |
## 범위 (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` (10 단위 테스트)
- `tests/unit/BackupService.test.ts` (14 단위 테스트)
## 후속 (별 spec 또는 후속 항목 후보)
- 외부 디렉터리 백업 (예: OneDrive 폴더 지정)
- 미디어 포함 백업
- 암호화 (SQLCipher / age)
- 백업 무결성 검증 (PRAGMA integrity_check)
- F6-L2 git sync 와 연결 (백업 디렉터리도 sync 대상에 포함할지)
- KST 헬퍼 추출 (현재 ContinuityService + BackupService + recoveryToast 3개 인라인 — 다음 consumer 시 추출)
- WAL 모드 source DB 통합 검증 테스트
- runDaily 마커의 read-then-write race 보호 (현재 단일 사용자라 사실상 무시 가능)
- 시작 시점 `*.sqlite.tmp` orphan sweep — Windows 강제 종료(`session-end`) 가 `db.backup()` 중간에 들어올 때 잔존 가능. `parseBackupFilename``.tmp` 를 거부하므로 rotation 에 영향 0 이지만 디스크 누수 차원
- `rotate()` 부분 실패 허용 — `unlink` 한 파일이 antivirus 등으로 잠긴 경우 현재는 전체 abort. 파일별 try/catch + `partial_rotation` 로깅 권장
- `before-quit` hook 이 `whenReady()` 안에 있어서 whenReady 도달 전 quit 시 미설치 — 보통은 OK (백업할 DB 자체가 없는 시점) 지만 코멘트 권장
- **F1 마이그레이션 전 강제 스냅샷** — `openDb()``runMigrations()` 를 호출 후 `BackupService` 가 인스턴스화되므로 v2 마이그레이션 결함 시 첫 실행 직전 상태 회수 불가. F1 (Due Date) PR 시 마이그레이션 직전 스냅샷 단계 추가 권장

View File

@@ -0,0 +1,229 @@
# Dogfood 피드백 로드맵 (F1~F6 → v0.2.1) 설계
**작성일:** 2026-04-26
**저자:** 김태현 (dlsrks0734@gmail.com)
**문서 성격:** v0.2.0 dogfood 와 병렬로 진행되는 F1~F6 항목 8개의 순차 작업 로드맵. 본 문서는 **순서·범위·게이트** 만 정의하며, 각 항목 내부 설계는 항목별 mini-brainstorm + writing-plans 단계에서 결정.
**선행 문서:**
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` (F1~F6 raw/drafting 수집)
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` (slice v0.4 본문, §1·§5·§7)
- `docs/superpowers/strategy/strategy.md` (심리학 전략, F3·F4-E 동반 갱신 대상)
- `docs/superpowers/strategy/dogfood-strategy.md` (dogfood 운영안)
---
## 1. 결정 요약
| 결정 | 값 | 근거 |
|------|-----|------|
| 우선순위 기준 | **데이터 안전 우선** | dogfood 진행 중 손실 위험 즉시 차단. F6 의 raw 발견이 시기적으로 가장 무거운 신호. |
| 항목당 게이트 | **머지 + 테스트 통과** (typecheck + 52+ 단위 + e2e smoke) | 빠른 회전. 새 빌드는 누적 후 한 번에 cut. |
| 시작 항목 | **F6-L1 로컬 스냅샷** | 안전 우선 + 작은 범위 + 독립 (외부 dep 0). |
| 순서 | **데이터 라이프사이클 → 기능 → 카피 → 무거운 sync → cue 강화** (Option X) | 백업·export·import 안전망 완성 후 schema 변경 (F1) 진입. e2e 흔드는 카피 변경은 늦게 배치. |
| 다음 빌드 | **v0.2.1** (8개 모두 머지 후 단일 cut) | slice §7 strict-pin 컨벤션의 patch 증분. |
| F4-A·D | **deferred** — v0.2.1 dogfood soak 후 측정 데이터 기반 별도 brainstorm | 측정 인프라가 cut 시점부터 활성. 사전 결정 위험 회피. |
| Decision-pending 처리 | **항목별 mini-brainstorm** | 본 문서는 순서만, 항목 내부는 per-item. |
---
## 2. 순차 작업 순서
```
v0.2.0 ────────[ dogfood 동결, 병렬 진행 ]────────
개발 트랙 (main 직접 머지): │
① F6-L1 로컬 스냅샷 [작음, 안전 즉시] │
② F5 Export [중, 의존성 잠금해제] │
③ F6-L3 Import [작음, F5 직후] │
④ F1 Due Date [큼, migration v2] │
⑤ F2 태그 클릭 [작음, 독립] │
⑥ F3+F4-E 카피·strategy 정리 [중, e2e 영향] │
⑦ F6-L2 Git sync [가장 큼] │
⑧ F4-C·F cue 강화 [작음] │
┌──────────┘
v0.2.1 cut (단일)
dogfood 재설치 + ≥ 1주 soak
F4-A·D (측정 후 별 brainstorm)
```
### 2.1 순서 결정 근거 (요약)
1. **F6-L1 (1번)** — 안전 우선. SQLite `db.backup()` 만 사용, 외부 dep 0, 다른 항목 무영향.
2. **F5 (2번)** — F4-H5 측정 + F6-L3 import + F6-L2 git sync 의 dependency. 잠금해제 효율 1위.
3. **F6-L3 (3번)** — F5 의 역방향. 형식 동일, ImportService 만 추가. 데이터 라이프사이클 3종 세트 완성.
4. **F1 (4번)** — schema migration v2 가 백업·export·import 인프라 위에서 진행되어 회수 가능. migration 결함 시 v0.2.0 백업으로 복원 가능.
5. **F2 (5번)** — 작은 renderer 변경. F1 schema 변경과 충돌 없음.
6. **F3+F4-E (6번)** — strategy.md §1·§3·§7 의 "기억 구출" 어휘 결정 + e2e smoke 단언 변경 + Zeigarnik priming 카피. 한 PR 으로 묶음. e2e 흔드는 위치를 늦게 배치하여 다른 항목 머지 흐름 보호.
7. **F6-L2 (7번)** — git CLI 의존성 + safeStorage + 동기화 worker. 가장 무거움. F5/F6-L1/F6-L3 인프라 안정 후.
8. **F4-C·F (8번)** — 트레이 뱃지 + 정체성 카피. F3 카피 톤 결정 후 톤 통일. 가벼움.
---
## 3. 항목당 범위 (In/Out)
각 항목의 PR 범위 라인. 세부 결정 (decision-pending) 은 항목 시작 시 mini-brainstorm 에서.
| # | 항목 | In (이 PR 범위) | Out (다음 항목 또는 후속) |
|---|------|----------------|--------------------------|
| 1 | **F6-L1** 로컬 스냅샷 | `BackupService` + `db.backup()` 래핑 + GFS 로테이션 (일 14·주 4·월 6) + 트레이 메뉴 "지금 백업" + 종료 hook (`before-quit`) + `.last-snapshot` 마커 + 단위 테스트 (로테이션) | 외부 디렉터리 백업, 암호화, 미디어 포함, 자동 복원 |
| 2 | **F5** Export | `ExportService` + frontmatter 마크다운 (one-file-per-note) + `index.jsonl` + `manifest.json` + `README.md` 동봉 + 미디어 평탄화 복사 + 트레이 메뉴 "내보내기..." + 폴더 선택 다이얼로그 + 단위 테스트 (frontmatter 합성, 슬러그) | 증분 export, CLI 플래그, watch-mode, CSV/JSON 형식 |
| 3 | **F6-L3** Import | `ImportService` + 충돌 미리보기 다이얼로그 (n 신규, m 변경, k 충돌) + 트레이 메뉴 "백업에서 복원..." + 단위 테스트 (id 충돌 정책) | git remote 통한 import, 충돌 자동해결, 마이그레이션 자동 |
| 4 | **F1** Due Date | migration v2 (`due_date TEXT`, `due_date_edited_by_user INTEGER`) + 규칙 파서 (정규식 + KST 변환) + zod 스키마 확장 + AI 프롬프트 `{{TODAY_KST}}` + NoteCard 라벨 슬롯 + EditableField 재사용 + 골든 픽스처 50건 | 음력·시각 단위·반복 일정, 만료 처리 정책, 별도 due 뷰 |
| 5 | **F2** 태그 클릭 | NoteCard 칩 onClick 변경 (필터) + ✕ 아이콘 추가 + 5초 undo 토스트 + zustand `tagFilter` + Inbox 헤더 필터 칩 | 다중 태그 필터, rename/merge, 자동완성 |
| 6 | **F3+F4-E** 카피 정리 | NotificationService 회전 카피 #3 교체 + 트레이 라벨 2개 + 빈 상태 + QC 힌트 + `package.json description` + e2e smoke 단언 동기화 + slice §5.5 카피 테이블 + strategy.md §1·§3·§7 어휘 갱신 + Zeigarnik priming 카피 1줄 | 회전 카피 4종 전면 재작성, onboarding, 다국어 |
| 7 | **F6-L2** Git sync | `SyncService` (BackgroundSyncWorker) + `GitClient` (CLI 래퍼) + `CredentialStore` (safeStorage 래퍼) + Settings 창 신설 (remote URL · token · 주기 · 미디어 포함 · 충돌 정책) + 5분 debounce + 종료 시 push + 충돌 다이얼로그 (3-way 선택) + 트레이 상태 표시 | 자동 conflict resolution, 다중 활성 디바이스 sync, LFS, 암호화 |
| 8 | **F4-C·F** cue 강화 | 트레이 아이콘 동적 갱신 (오늘 캡처 0/N 색·뱃지) + `repo.countToday()` + Inbox 헤더 정체성 카운터 + 카피 1줄 ("오늘 N번 비웠습니다") | F4-A 잠금 hook, F4-D 랜덤 알림 (deferred) |
### 3.1 공통 게이트 (모든 항목)
각 항목 머지 전 필수:
- `npm run typecheck` 통과 (현재 0 에러)
- `npm test` 통과 (현재 52/52, 항목 신규 단위 추가)
- `npm run test:e2e` 통과 (현재 1/1)
- 항목 신규 단위 테스트 ≥ 1개 (TDD)
- main 머지 + dogfood-feedback.md 상태 🚀 promoted + 별 spec 분기
---
## 4. 항목당 작업 흐름
```
[항목 N 시작]
├─ mini-brainstorm ← 본 항목의 decision-pending 답변
│ - F<N>-spec 의 "결정 대기" 슬롯 채움
│ - dogfood-feedback.md 의 상태 🌱 → 🔬 → 📝 전이
├─ writing-plans ← TDD 구현 계획
│ - test-driven-development 스킬 사용
│ - 한 항목 = 한 plan
├─ 구현 (executing-plans 또는 직접)
│ - 브랜치: feat/F<N>-<short-slug> (예: feat/F1-due-date)
│ - 게이트 통과 후 main 머지
│ - 단일 PR 또는 main 직접 push (작은 항목 한정)
├─ dogfood-feedback.md 갱신
│ - 상태 → 🚀 promoted
│ - 별 spec 분기 → docs/superpowers/specs/2026-MM-DD-<topic>.md
│ - 본 문서엔 1줄 요약 + 링크만 남김
└─ 다음 항목 시작
```
### 4.1 Cross-cutting 정책
| 영역 | 정책 |
|------|------|
| **버전 관리** | 8개 모두 머지될 때까지 `package.json` `0.2.0` 유지. v0.2.1 cut 은 8번 후 단일. |
| **브랜치 전략** | `feat/F<N>-<slug>` 단명. main 머지 후 삭제. 작은 항목 (F6-L1, F2, F4-C·F) 은 main 직접 push 도 허용 (sandbox 정책 따름). |
| **테스트 추가 정책** | 항목당 최소 단위 1개. e2e smoke 영향 시 단언 동기화. integration (Ollama) 은 AI 호출 영향 시만. |
| **Slice invariant 위반 시** | 본 로드맵 결과로 invariant 변경 — slice spec §7 도 PR 안에 동봉 수정. |
| **F4-A·D deferred** | v0.2.1 dogfood soak (≥ 1주) 후 측정 데이터 보고 별도 brainstorm 진입. 본 로드맵 범위 외. |
| **dogfood-feedback.md 라이프사이클** | 항목 promoted 시 본 문서엔 1줄 + 링크. raw/drafting 항목은 그대로 누적. |
| **신규 dependency** | slice §7 strict-pin 그대로. 신규 dep 도입 시 PR 안에 §7.2 갱신 동봉. F6-L2 의 git CLI 는 시스템 의존이라 README 사전 요구 추가. |
| **로깅 정책** | slice §1.1 invariant 4 (raw_text/title/summary/intent 미기록) 유지. F5/F6 export·sync 로그도 노트 본문 미기록, ID·길이·해시 prefix 만. |
| **Strategy.md 동반 갱신** | F3+F4-E 항목 (6번) 에서만. 다른 항목은 strategy.md 미수정. |
---
## 5. v0.2.1 Cut 단계
8번 항목 머지 후:
```
[v0.2.0 dogfood 환경에서]
1. 트레이 → "지금 백업" 1회 클릭 ← F6-L1 첫 실증, 백업 1회 보장
2. 트레이 → "내보내기..." 1회 ← F5 첫 실증, 외부 백업 이중화
3. Inkling 종료 (트레이 → 종료) ← 설치 충돌 회피
[빌드 머신에서]
4. package.json version: 0.2.0 → 0.2.1
5. CHANGELOG.md (신설) 또는 git tag 메시지에 누적
* 결정 대기: CHANGELOG.md 신설 vs git tag 메시지 ─ 8번 항목 직전 mini-brainstorm 에서 결정
6. npm run dist
7. dist/Inkling Setup 0.2.1.exe 검증
[dogfood 머신에서]
8. Setup 0.2.1.exe 실행 → 같은 폴더에 설치 (위치 변경 시 side-by-side 위험)
9. 첫 실행 → migration v2 자동 적용 (F1 due_date 컬럼 추가)
10. 트레이 → "백업에서 복원..." 메뉴 존재 확인 (F6-L3 회로 통)
11. ≥ 1주 soak 시작
```
### 5.1 업그레이드 안전망
| 위험 | 완화 |
|------|------|
| migration v2 결함으로 DB 손상 | **2가지 복원 경로 보유**: (a) 단계 1 의 F6-L1 백업은 v0.2.0 schema 라 복원하려면 v0.2.0 인스톨러 재설치 후 백업 파일 교체 → 다시 v0.2.1 업그레이드 (migration 재시도 또는 fix 적용된 v0.2.2 대기). (b) 단계 2 의 F5 export 는 schema-agnostic 마크다운이라 v0.2.1 의 F6-L3 import 로 직접 복원 가능. (b) 가 더 빠른 회복 경로. |
| 설치 위치 변경 → side-by-side 잔존 | 설치 마법사에서 같은 폴더 선택 (기본값) |
| 앱 실행 중 설치 실패 | 단계 3 에서 종료 |
| 자동시작 토글 상태 손실 | `HKCU\...\Run` + `.autostart-init` 보존됨 (data dir 손대지 않음) |
| electron-updater 미설정 | 본 로드맵 범위 외. 사용자가 수동 다운로드 (gitea release 호스팅 후속 검토) |
---
## 6. 측정
### 6.1 로드맵 측정
| 메트릭 | 임계값 | 측정 방법 |
|--------|--------|----------|
| 항목 평균 PR 사이즈 | < 800 lines diff | git log 통계 |
| 항목 평균 머지 간격 | < 5일 | git log 시간차 |
| 회귀 테스트 추가 | 항목당 ≥ 1개 단위 테스트 | tests/unit 카운트 |
| dogfood-feedback.md 상태 전이 | 8/8 모두 🚀 promoted | grep |
| v0.2.1 cut 후 1주차 데이터 손실 | 0회 | 본인 라벨링 |
| typecheck/test 회귀 | 0회 (모든 항목 통과) | CI · 로컬 |
### 6.2 silent invariant 후보
본 로드맵 결과로 slice §1.3 종료 조건에 다음을 추가 권장:
> **"데이터 손실 0회"** — F6-L1 출시 후 모든 dogfood 사이클에 대해 데이터 손실 사건 0회. 발생 시 즉시 silent invariant 위반으로 간주.
이 추가는 F6-L1 항목 머지 시 slice spec §1.3 동봉 수정.
---
## 7. 본 로드맵의 종료 조건
**모두 만족해야 종결**:
1. F1·F2·F3+F4-E·F5·F6-L1·F6-L3·F6-L2·F4-C·F 8개 항목 모두 main 머지
2. 8개 모두 dogfood-feedback.md 에서 🚀 promoted 상태 + 별 spec 파일 분기
3. `package.json description`, README, slice spec §5.5 카피 테이블, strategy.md §1·§3·§7 동봉 갱신 완료
4. v0.2.1 cut → dogfood 머신 재설치 → migration v2 적용 확인 → 첫 실행 정상 + 트레이 메뉴 6개 항목 (지금 백업·내보내기·복원·자동시작·구출·종료, 카피 변경 반영) 동작 확인
5. ≥ 1주 dogfood soak 완료 (데이터 손실 0회 확인)
5 가 끝나면 본 로드맵 종결, F4-A·D 별 brainstorm 진입.
---
## 8. 미결정 항목 (각 항목 시작 시 답변)
본 로드맵은 순서만 정의했고, 각 F-spec 의 결정 대기 슬롯은 항목 시작 시 mini-brainstorm 에서 답함. 본 문서는 그 결정들을 미리 잠그지 않음.
특히 **다음 결정들은 빨리 마주치게 됨**:
- F5: 포맷 1차안 확정 (one-file-per-note + frontmatter + index.jsonl 트리플 가정), 미디어 포함 기본값, embedding_text 합성 규칙
- F6-L1: 백업 위치 (profileDir 안 vs 별 디렉터리 vs 사용자 지정 외부)
- F1: false positive 처리, due 만료 시 시각 표시 정책, 라벨 슬롯 위치
- F3+F4-E: strategy.md §1·§3·§7 동반 갱신 범위, 단일 동사 통일 vs 표면별 분배
- F6-L2: 첫 설정 UX 부담 vs opt-in, repo 분리 (`-data` 별 repo)
- 모든 항목: CHANGELOG.md 신설 vs git tag 메시지 (8번 항목 직전 mini-brainstorm)
---
## 9. 변경 이력
| 일자 | 변경 |
|------|------|
| 2026-04-26 | 초안 — F1~F6 의 8개 항목 순차 로드맵, 데이터 안전 우선 (Option A), 머지+테스트 게이트 (Option A), 데이터 라이프사이클 우선 순서 (Option X), v0.2.1 단일 cut |

3064
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
"name": "inkling",
"version": "0.2.0",
"private": true,
"description": "Inkling — local-first 기억 구출 도구",
"author": "altair823 <dlsrks0734@gmail.com>",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
@@ -18,11 +20,37 @@
"test:watch": "vitest",
"test:integration": "INKLING_INTEGRATION=1 vitest run tests/integration",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"predist": "npm run rebuild:electron && npm run build",
"dist": "electron-builder --win --x64",
"predist:dir": "npm run rebuild:electron && npm run build",
"dist:dir": "electron-builder --dir --win --x64"
},
"build": {
"appId": "xyz.altair823.inkling",
"productName": "Inkling",
"files": [
"out/**/*",
"package.json"
],
"asarUnpack": [
"**/*.node"
],
"win": {
"target": [
{ "target": "nsis", "arch": ["x64"] }
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"shortcutName": "Inkling"
}
},
"dependencies": {
"better-sqlite3": "12.9.0",
"electron": "41.3.0",
"electron-log": "5.2.0",
"react": "19.2.5",
"react-dom": "19.2.5",
@@ -37,6 +65,8 @@
"@types/react": "19.0.0",
"@types/react-dom": "19.0.0",
"@vitejs/plugin-react": "5.1.4",
"electron": "41.3.0",
"electron-builder": "26.8.1",
"electron-vite": "5.0.0",
"typescript": "6.0.3",
"undici": "8.1.0",

View File

@@ -1,6 +1,8 @@
import electron from 'electron';
const { app, BrowserWindow, Notification } = electron;
import '@shared/types';
import { existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { initLogger, logger } from './logger.js';
import { resolveProfilePaths } from './paths.js';
import { openDb } from './db/index.js';
@@ -22,12 +24,30 @@ import {
} from './windows/quickCaptureWindow.js';
import { createTray } from './tray.js';
import { MediaGc } from './services/MediaGc.js';
import { BackupService } from './services/BackupService.js';
const HIDDEN_ARG = '--hidden';
const startedHidden = process.argv.includes(HIDDEN_ARG);
app.whenReady().then(async () => {
initLogger();
logger.info('app.start', { platform: process.platform, version: app.getVersion() });
logger.info('app.start', {
platform: process.platform,
version: app.getVersion(),
packaged: app.isPackaged,
hidden: startedHidden
});
const paths = resolveProfilePaths('default');
if (app.isPackaged && process.platform === 'win32') {
const initFlag = join(paths.profileDir, '.autostart-init');
if (!existsSync(initFlag)) {
app.setLoginItemSettings({ openAtLogin: true, args: [HIDDEN_ARG] });
writeFileSync(initFlag, new Date().toISOString());
logger.info('autostart.enabled.firstRun');
}
}
const db = openDb(paths.dbFile);
const repo = new NoteRepository(db);
const store = new MediaStore(paths.profileDir);
@@ -73,21 +93,59 @@ app.whenReady().then(async () => {
});
if (!reg.ok) logger.warn('hotkey.register.failed', { reason: reg.reason });
createInboxWindow();
if (!startedHidden) {
createInboxWindow();
}
createQuickCaptureWindow();
createTray(
() => createInboxWindow(),
() => showQuickCapture()
);
await worker.loadFromDb();
const gc = new MediaGc(db, store);
void gc.run().then((r) => logger.info('media.gc', { ...r } as Record<string, unknown>));
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) }));
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();
});
});
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();
}
}
);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
});
});
app.on('before-quit', () => { app.isQuitting = true; });

View File

@@ -0,0 +1,105 @@
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()
) {}
/**
* Atomic snapshot of the SQLite DB to `inkling-YYYY-MM-DD.sqlite` (KST).
*
* NOT safe for concurrent calls — two parallel calls race on the same
* tmp/final path. Callers should serialize via {@link runDaily}, which
* gates on the `.last-snapshot` marker.
*/
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`;
try {
await this.db.backup(tmpPath);
await rename(tmpPath, finalPath);
} catch (e) {
await unlink(tmpPath).catch(() => { /* tmp may not exist */ });
throw e;
}
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
};
}
}

View File

@@ -0,0 +1,77 @@
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_PRIOR_MONDAYS = 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 startOfDayUtc(d: Date): Date {
const x = new Date(d);
x.setUTCHours(0, 0, 0, 0);
return x;
}
function isWithinDailyWindow(fileDate: Date, today: Date): boolean {
const oldest = new Date(today.getTime() - (DAILY_WINDOW_DAYS - 1) * ONE_DAY_MS);
return fileDate >= oldest && fileDate <= today;
}
function isWithinWeeklyWindow(fileDate: Date, today: Date): boolean {
// UTC-based Monday detection. UTCDay: 0=Sun, 1=Mon..6=Sat
if (fileDate.getUTCDay() !== 1) return false;
// Weekly window: anchor on the most recent Monday on/before `today`, then reach back
// WEEKLY_WINDOW_PRIOR_MONDAYS * 7 days from that anchor. Effective semantic:
// up to 5 distinct Mondays kept (the anchor Monday + 4 prior). The plan's commit
// header says "4 weekly Mondays" but the plan's test case at
// backupRotation.test.ts requires 03-23 (5th Monday from 2026-04-26) to be kept,
// so the test is treated as source of truth.
const dayOfWeek = today.getUTCDay(); // 0=Sun..6=Sat
const daysSinceMonday = (dayOfWeek + 6) % 7; // Mon=0, Sun=6
const lastMonday = new Date(today.getTime() - daysSinceMonday * ONE_DAY_MS);
const oldest = new Date(lastMonday.getTime() - WEEKLY_WINDOW_PRIOR_MONDAYS * 7 * ONE_DAY_MS);
return fileDate >= oldest && fileDate <= today;
}
function isWithinMonthlyWindow(fileDate: Date, today: Date): boolean {
if (fileDate.getUTCDate() !== 1) return false;
// 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[] = [];
const today = startOfDayUtc(now);
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 > today) {
keep.push(name); // future-dated — clock skew safety
continue;
}
const survives =
isWithinDailyWindow(fileDate, today) ||
isWithinWeeklyWindow(fileDate, today) ||
isWithinMonthlyWindow(fileDate, today);
if (survives) keep.push(name);
else remove.push(name);
}
return { keep, remove };
}

View File

@@ -1,20 +1,50 @@
import electron from 'electron';
import type { Tray as TrayType } from 'electron';
import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron';
const { app, Tray, Menu, nativeImage } = electron;
let tray: TrayType | null = null;
export function createTray(showInbox: () => void, showCapture: () => void): TrayType {
const icon = nativeImage.createEmpty();
tray = new Tray(icon);
tray.setToolTip('Inkling');
const menu = Menu.buildFromTemplate([
function buildMenu(
showInbox: () => void,
showCapture: () => void,
runBackup: () => void
) {
const items: MenuItemConstructorOptions[] = [
{ label: '구출한 메모 보기', click: showInbox },
{ label: '기억 구출하기', click: showCapture },
{ type: 'separator' },
{ label: '종료', click: () => { app.isQuitting = true; app.quit(); } }
]);
tray.setContextMenu(menu);
{ 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;
}

View File

@@ -0,0 +1,171 @@
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';
import { readFileSync, writeFileSync } from 'node:fs';
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);
});
it('cleans up orphan .tmp file when db.backup() fails', async () => {
// Inject a fake db whose backup() rejects to simulate disk-full / IO-error.
const fakeDb = {
backup: async (tmpPath: string) => {
// Simulate a partial write before the failure (real failures may leave bytes).
await import('node:fs/promises').then((fs) =>
fs.writeFile(tmpPath, 'partial-bytes')
);
throw new Error('simulated disk full');
}
} as unknown as Database.Database;
const svc = new BackupService(fakeDb, dir, () => new Date('2026-04-26T12:00:00Z'));
await expect(svc.snapshot()).rejects.toThrow('simulated disk full');
// No leftover .tmp file
const remaining = readdirSync(dir);
expect(remaining.filter((f) => f.endsWith('.tmp'))).toEqual([]);
// No final file either (write never completed)
expect(remaining.filter((f) => f.endsWith('.sqlite'))).toEqual([]);
});
});
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');
});
});

View File

@@ -0,0 +1,108 @@
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();
});
it('returns null for date that JS would silently coerce (roundtrip lock-in)', () => {
// Without the toISOString roundtrip check, JS coerces 2026-02-30 to 2026-03-02.
// This test locks in the roundtrip-validation contract.
expect(parseBackupFilename('inkling-2026-02-30.sqlite')).toBeNull();
});
});
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('weekly window is inclusive at oldest boundary', () => {
// 2026-03-23 is exactly 4*7 days before anchor Monday 2026-04-20.
// Locks in the boundary semantic explicitly.
const r = applyGfsRetention(names('2026-03-23', '2026-03-16'), NOW);
expect(r.keep).toContain('inkling-2026-03-23.sqlite');
expect(r.remove).toContain('inkling-2026-03-16.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([]);
});
});