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

|
||||
```
|
||||
|
||||
**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개 채우거나 비워둔 채 시작 가능.
|
||||
|
||||
40
docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md
Normal file
40
docs/superpowers/specs/2026-04-26-f6-l1-local-snapshot.md
Normal 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 시 마이그레이션 직전 스냅샷 단계 추가 권장
|
||||
229
docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md
Normal file
229
docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md
Normal 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
3064
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -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",
|
||||
|
||||
@@ -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; });
|
||||
|
||||
105
src/main/services/BackupService.ts
Normal file
105
src/main/services/BackupService.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
77
src/main/services/backupRotation.ts
Normal file
77
src/main/services/backupRotation.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
171
tests/unit/BackupService.test.ts
Normal file
171
tests/unit/BackupService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
108
tests/unit/backupRotation.test.ts
Normal file
108
tests/unit/backupRotation.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user