Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/interactive-os/plugins/persist.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
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<Picked>({ 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<Picked>({ 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<Picked>({ 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<Picked>({ 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<Picked>({
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<Picked>({ 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()
})
})
133 changes: 133 additions & 0 deletions src/interactive-os/plugins/persist.ts
Original file line number Diff line number Diff line change
@@ -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<Picked> {
/** 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<Picked> = PersistBaseOptions<Picked>

export interface PersistOptions<Picked> extends PersistBaseOptions<Picked> {
/** 저장 대상 추출. 전체 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<Picked>(options: LoadPersistedOptions<Picked>): 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<Picked>(
options: Pick<PersistBaseOptions<Picked>, '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<Picked>(options: PersistOptions<Picked>): Plugin {
const storage = options.storage ?? defaultStorage()
const debounceMs = options.debounce ?? 200
let prevSerialized: string | null = null
let timer: ReturnType<typeof setTimeout> | 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()))
},
})
}
Loading