From ae7d656ccdf03cad161801e355dcc9e282d2995f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Tue, 21 Apr 2026 13:59:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(os):=20persist=20plugin=20+=20localStorage?= =?UTF-8?q?=20=ED=9D=A1=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createStore 초기화 시 localStorage에서 상태 복원 - command 실행 후 상태를 localStorage에 직렬화 - 6 tests passing - 관련 PRD: docs/2026/2026-04/2026-04-20/persistPluginPrd.md --- src/interactive-os/plugins/persist.test.ts | 129 ++++++++++++++++++++ src/interactive-os/plugins/persist.ts | 133 +++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/interactive-os/plugins/persist.test.ts create mode 100644 src/interactive-os/plugins/persist.ts diff --git a/src/interactive-os/plugins/persist.test.ts b/src/interactive-os/plugins/persist.test.ts new file mode 100644 index 000000000..35007157a --- /dev/null +++ b/src/interactive-os/plugins/persist.test.ts @@ -0,0 +1,129 @@ +// V1: persistPluginPrd.md +import { describe, it, expect, vi } from 'vitest' +import { loadPersisted, persist } from './persist' +import type { PersistAdapter } from './persist' +import { createCommandEngine } from '../engine/createCommandEngine' +import { buildRegistry } from '../engine/types' +import type { NormalizedData } from '../store/types' + +function makeMemoryStorage() { + const map = new Map() + let setCount = 0 + const adapter: PersistAdapter = { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => { setCount++; map.set(k, v) }, + } + return { adapter, map, getSetCount: () => setCount } +} + +const makeStore = (label = 'A'): NormalizedData => ({ + entities: { + __focus__: { id: '__focus__', focusedId: 'a' }, + a: { id: 'a', data: { label } }, + }, + relationships: { __root__: ['a'] }, +}) + +const setLabel = { + type: 'test:setLabel', + handler: (store: NormalizedData, payload: unknown) => { + const { label } = payload as { label: string } + return { + ...store, + entities: { + ...store.entities, + a: { ...store.entities.a, data: { label } }, + }, + } + }, +} + +type Picked = { label: string } +const pick = (s: NormalizedData): Picked => ({ label: (s.entities.a?.data as { label: string }).label }) + +describe('persist plugin', () => { + it('restores picked state synchronously via loadPersisted', () => { + const { adapter, map } = makeMemoryStorage() + map.set('k', JSON.stringify({ v: 1, d: { label: 'restored' } })) + + const loaded = loadPersisted({ key: 'k', version: 1, storage: adapter }) + expect(loaded).toEqual({ label: 'restored' }) + }) + + it('writes picked state after command (debounced)', () => { + vi.useFakeTimers() + const { adapter, map } = makeMemoryStorage() + const plugin = persist({ key: 'k', version: 1, pick, storage: adapter, debounce: 200 }) + const registry = buildRegistry({ setLabel }) + const engine = createCommandEngine(makeStore(), [plugin.middleware!], registry, () => {}, { logger: false }) + + engine.dispatch({ type: 'test:setLabel', payload: { label: 'B' } }) + + expect(map.get('k')).toBeUndefined() + + vi.advanceTimersByTime(200) + expect(map.get('k')).toBe(JSON.stringify({ v: 1, d: { label: 'B' } })) + vi.useRealTimers() + }) + + it('skips write when picked is unchanged', () => { + vi.useFakeTimers() + const { adapter, getSetCount } = makeMemoryStorage() + const plugin = persist({ key: 'k', version: 1, pick, storage: adapter, debounce: 50 }) + const registry = buildRegistry({ setLabel }) + const engine = createCommandEngine(makeStore('A'), [plugin.middleware!], registry, () => {}, { logger: false }) + + engine.dispatch({ type: 'test:setLabel', payload: { label: 'A' } }) + vi.advanceTimersByTime(50) + const countAfterFirst = getSetCount() + engine.dispatch({ type: 'test:setLabel', payload: { label: 'A' } }) + vi.advanceTimersByTime(50) + expect(getSetCount()).toBe(countAfterFirst) + vi.useRealTimers() + }) + + it('calls migrate when version mismatches', () => { + const { adapter, map } = makeMemoryStorage() + map.set('k', JSON.stringify({ v: 0, d: { legacyLabel: 'old' } })) + let seenOldV: number | null = null + const migrate = (old: unknown, oldV: number): Picked | undefined => { + seenOldV = oldV + return { label: (old as { legacyLabel: string }).legacyLabel } + } + + const loaded = loadPersisted({ key: 'k', version: 1, storage: adapter, migrate }) + expect(seenOldV).toBe(0) + expect(loaded).toEqual({ label: 'old' }) + }) + + it('falls back to undefined when migrate returns undefined', () => { + const { adapter, map } = makeMemoryStorage() + map.set('k', JSON.stringify({ v: 0, d: { x: 1 } })) + const loaded = loadPersisted({ + key: 'k', version: 1, storage: adapter, + migrate: () => undefined, + }) + expect(loaded).toBeUndefined() + }) + + it('swallows setItem exceptions', () => { + vi.useFakeTimers() + const warnMsgs: unknown[] = [] + const warn = vi.spyOn(console, 'warn').mockImplementation((...args) => { warnMsgs.push(args) }) + const storage: PersistAdapter = { + getItem: () => null, + setItem: () => { throw new Error('quota') }, + } + const plugin = persist({ key: 'k', version: 1, pick, storage, debounce: 10 }) + const registry = buildRegistry({ setLabel }) + const engine = createCommandEngine(makeStore(), [plugin.middleware!], registry, () => {}, { logger: false }) + + expect(() => { + engine.dispatch({ type: 'test:setLabel', payload: { label: 'Z' } }) + vi.advanceTimersByTime(10) + }).not.toThrow() + expect(warnMsgs.length).toBeGreaterThan(0) + warn.mockRestore() + vi.useRealTimers() + }) +}) diff --git a/src/interactive-os/plugins/persist.ts b/src/interactive-os/plugins/persist.ts new file mode 100644 index 000000000..808c8840f --- /dev/null +++ b/src/interactive-os/plugins/persist.ts @@ -0,0 +1,133 @@ +// ② persistPluginPrd.md +import type { Command, Plugin } from '../engine/types' +import type { NormalizedData } from '../store/types' +import { definePlugin } from './definePlugin' + +export interface PersistAdapter { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface PersistBaseOptions { + /** localStorage key */ + key: string + /** 스키마 버전. 저장물과 다르면 migrate 호출. */ + version: number + /** old → current 변환. 실패 시 undefined 반환하면 저장물 폐기. */ + migrate?: (oldPicked: unknown, oldVersion: number) => Picked | undefined + /** 기본 localStorage. 테스트/대체 어댑터 주입용. */ + storage?: PersistAdapter + /** + * raw string → Picked. envelope `{v,d}` 우회. + * 기존 raw 저장물(legacy)과 호환하려면 제공. + * 반환 undefined면 저장물 폐기. + */ + parse?: (raw: string) => Picked | undefined + /** + * Picked → raw string. envelope `{v,d}` 우회. + * parse와 쌍으로 제공. + */ + serialize?: (picked: Picked) => string +} + +export type LoadPersistedOptions = PersistBaseOptions + +export interface PersistOptions extends PersistBaseOptions { + /** 저장 대상 추출. 전체 store를 저장하지 않음. */ + pick: (store: NormalizedData) => Picked + /** 쓰기 debounce ms. 기본 200. */ + debounce?: number +} + +interface Envelope { + v: number + d: unknown +} + +function defaultStorage(): PersistAdapter | null { + if (typeof localStorage === 'undefined') return null + return { + getItem: (k) => localStorage.getItem(k), + setItem: (k, v) => { localStorage.setItem(k, v) }, + } +} + +/** + * engine 생성 *이전* 동기 로드. 저장물이 없거나 version mismatch + migrate 실패 시 undefined. + * + * @invariant localStorage 미정의·JSON parse 실패·storage throw → undefined + * @invariant version 일치 → 저장된 picked 반환 + * @invariant version 불일치 → migrate 호출, 반환값(undefined 포함) 그대로 전달 + */ +export function loadPersisted(options: LoadPersistedOptions): Picked | undefined { + const storage = options.storage ?? defaultStorage() + if (!storage) return undefined + try { + const raw = storage.getItem(options.key) + if (raw == null) return undefined + if (options.parse) return options.parse(raw) + const env = JSON.parse(raw) as Envelope + if (env.v === options.version) return env.d as Picked + return options.migrate?.(env.d, env.v) + } catch { + return undefined + } +} + +/** + * engine 비(非)사용 write 헬퍼. FlatLayout·Map 기반 외부 store처럼 + * createCommandEngine 없이 localStorage 쓰기가 필요한 케이스용. + * + * @invariant storage 미정의·setItem throw → swallow + * @invariant serialize 있으면 serialize(value), 없으면 envelope `{v,d}` JSON + */ +export function writePersisted( + options: Pick, 'key' | 'version' | 'storage' | 'serialize'>, + value: Picked, +): void { + const storage = options.storage ?? defaultStorage() + if (!storage) return + try { + const raw = options.serialize + ? options.serialize(value) + : JSON.stringify({ v: options.version, d: value } satisfies Envelope) + storage.setItem(options.key, raw) + } catch (e) { console.warn('[persist] write failed:', e) } +} + +/** + * Plugin: command 실행 후 debounced write로 localStorage에 반영. + * + * @invariant EffectContext를 변경하지 않음 (read-only 계약 보전) + * @invariant command 실행 후 pick 결과가 이전 직렬화와 동일하면 write 스킵 + * @invariant write는 debounce ms 내 연타 시 마지막 값만 실제 반영 + * @invariant storage.setItem throw는 console.warn 후 swallow + */ +export function persist(options: PersistOptions): Plugin { + const storage = options.storage ?? defaultStorage() + const debounceMs = options.debounce ?? 200 + let prevSerialized: string | null = null + let timer: ReturnType | null = null + + function scheduleWrite(picked: Picked) { + if (!storage) return + const next = options.serialize + ? options.serialize(picked) + : JSON.stringify({ v: options.version, d: picked } satisfies Envelope) + if (next === prevSerialized) return + prevSerialized = next + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + try { storage.setItem(options.key, next) } + catch (e) { console.warn('[persist] setItem failed:', e) } + }, debounceMs) + } + + return definePlugin({ + name: 'persist', + middleware: (next: (cmd: Command) => void, getStore: () => NormalizedData) => (cmd: Command) => { + next(cmd) + scheduleWrite(options.pick(getStore())) + }, + }) +}