// 檔案系統與時鐘皆由外部注入;核心只依賴這兩個介面 export interface Fs { read(path: string): Promise write(path: string, data: string): Promise /** 沒有 rename 的環境(hooks 沙箱)會直接覆寫 */ rename?(from: string, to: string): Promise list(dir: string): Promise } export type Clock = { now(): number } // 先寫暫存檔再 rename,當機時不會留下寫到一半的檔案 export async function writeAtomic(fs: Fs, path: string, data: string): Promise { if (!fs.rename) return fs.write(path, data) const tmp = `${path}.tmp` await fs.write(tmp, data) await fs.rename(tmp, path) } export async function readJson(fs: Fs, path: string): Promise { const raw = await fs.read(path) if (raw === null) return null try { return JSON.parse(raw) as T } catch { return null } } export const writeJson = (fs: Fs, path: string, value: unknown) => writeAtomic(fs, path, JSON.stringify(value, null, 2)) export function memoryFs(seed: Record = {}): Fs & { files: Map } { const files = new Map(Object.entries(seed)) return { files, async read(path) { return files.get(path) ?? null }, async write(path, data) { files.set(path, data) }, async rename(from, to) { const v = files.get(from) if (v === undefined) throw new Error(`ENOENT: ${from}`) files.delete(from) files.set(to, v) }, async list(dir) { const prefix = dir.endsWith('/') ? dir : dir + '/' const names = new Set() for (const k of files.keys()) if (k.startsWith(prefix)) names.add(k.slice(prefix.length).split('/')[0]!) return [...names].filter(n => !n.endsWith('.tmp')).sort() }, } } export function fakeClock(start = Date.parse('2026-10-12T00:00:00Z')) { let t = start return { now: () => t, advance: (ms: number) => void (t += ms), set: (ms: number) => void (t = ms) } }