/** * 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版) * * ============================================================================ * 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1) * ============================================================================ * 旧 mock(tests/helpers/opfs-mock.ts)有三处与真实 OPFS 语义不符,会掩盖真实缺陷: * 1. `read()` 返回内部 ArrayBuffer **引用**(真实 OPFS 返回快照副本) * → 调用方原地修改会"污染磁盘",而真实环境不会; * 2. `write()` 中 `if (keepExisting || position > 0)` 会在 keepExistingData:false * 时也保留旧字节(真实 OPFS 该场景应截断); * 3. `close()` 是空函数、写入**立即对读可见**(真实 OPFS 是写 swap 文件、 * close 时才原子替换)。因此"提交前可见"这类缺陷在旧 mock 下永远测不出来。 * * 更关键的是:**没有任何 mock 能表达"半写/撕裂/丢失一次写/部分删除失败"**, * 而项目所有"崩溃恢复"测试用的都是 `backend.close()`(优雅停机,会把写队列刷完), * 于是崩溃相关的声称在结构上无法被验证。 * * ============================================================================ * 本实现的两层设计 * ============================================================================ * 第 1 层 `TransactionalFileStore`: * - 纯数据 + 字节级故障注入 + 崩溃模拟(commit/discard),**不依赖任何浏览器 API**。 * 可直接用于单元测试:`store.write('k', buf); store.crashPending(); store.commitAll();` * - 忠实实现 OPFS 的写语义:createWritable 后写入进入 pending 区,close 时原子提交; * crash 丢弃全部 pending,已提交内容保持崩溃前状态。 * * 第 2 层 `installOPFSMock()`: * - 把 store 包装成 `navigator.storage.getDirectory()` 的 OPFS 门面,供 OPFSBackend 使用。 * - 读返回**副本**(不再泄漏内部引用)、keepExistingData:false 时截断、 * close 前不可见(与真实 OPFS 一致)。 * * 故障注入 API(供故障矩阵测试使用): * - `failNextWrite(n)` / `failNextAppend(n)` / `failNextDelete(n)` * - `truncateNextAppendTo(n)` —— 追加只写入前 n 字节(撕裂写) * - `crashPending()` —— 丢弃全部未提交写入(模拟进程崩溃) * - `commitAll()` —— 提交全部 pending(模拟 createWritable.close 全部完成) */ import type { IStorageBackend } from '../../src/engine/aria/store/backend'; // --------------------------------------------------------------------------- // 第 1 层:事务性文件存储(无浏览器依赖,可直接单测) // --------------------------------------------------------------------------- export interface TransactionalFileStoreOptions { /** 库名(仅用于日志/调试) */ dbName?: string; } /** 一次写入的故障注入配置 */ interface FaultConfig { failWrite: number; failAppend: number; failDelete: number; /** 追加写只落盘前 N 字节(撕裂写);-1 表示不启用 */ truncateAppendTo: number; } export class TransactionalFileStore { /** 已提交内容(= 真实"磁盘"状态) */ private committed = new Map(); /** 未提交写入(= OPFS createWritable 的 swap 区) */ private pending = new Map(); private readonly dbName: string; private faults: FaultConfig = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 }; /** 统计(供断言"到底发生了几次 IO") */ readonly stats = { writes: 0, appends: 0, deletes: 0, reads: 0, commits: 0, crashes: 0 }; constructor(options: TransactionalFileStoreOptions = {}) { this.dbName = options.dbName ?? 'mock'; } get name(): string { return this.dbName; } // ---- 故障注入 ---- /** 接下来 n 次 write() 抛错 */ failNextWrite(n = 1): void { this.faults.failWrite = n; } /** 接下来 n 次 append() 抛错 */ failNextAppend(n = 1): void { this.faults.failAppend = n; } /** 接下来 n 次 delete() 抛错(模拟 OPFS removeEntry 失败) */ failNextDelete(n = 1): void { this.faults.failDelete = n; } /** 下一次 append() 只落盘前 n 字节(撕裂写:模拟写入中途断电) */ truncateNextAppendTo(n: number): void { this.faults.truncateAppendTo = n; } /** 清空全部故障注入 */ clearFaults(): void { this.faults = { failWrite: 0, failAppend: 0, failDelete: 0, truncateAppendTo: -1 }; } // ---- 崩溃 / 提交 ---- /** * 模拟进程崩溃:丢弃**全部**未提交写入。 * 已提交内容保持崩溃前状态(这正是 OPFS copy-on-write 的保证)。 */ crashPending(): void { this.pending.clear(); this.stats.crashes++; } /** 模拟所有未完成的 createWritable 正常 close:把 pending 原子提交 */ commitAll(): void { for (const [key, value] of this.pending) { if (value === null) this.committed.delete(key); else this.committed.set(key, value); } this.pending.clear(); this.stats.commits++; } /** 是否存在未提交写入(测试可断言"崩溃窗口"是否真的存在) */ hasPending(): boolean { return this.pending.size > 0; } // ---- 数据操作(与 IStorageBackend 语义对齐,供 FaultyBackend 复用) ---- async read(key: string): Promise { this.stats.reads++; // 读取只看已提交内容 —— 未 close 的写入对读不可见(真实 OPFS 语义) const buf = this.committed.get(key); if (!buf) return null; return copyBuffer(buf); // 返回副本,杜绝调用方原地修改污染"磁盘" } async write(key: string, data: ArrayBuffer): Promise { if (this.faults.failWrite > 0) { this.faults.failWrite--; throw new Error(`[OPFS-mock] injected write failure on "${key}"`); } this.stats.writes++; this.pending.set(key, copyBuffer(data)); } async append(key: string, data: ArrayBuffer): Promise { if (this.faults.failAppend > 0) { this.faults.failAppend--; throw new Error(`[OPFS-mock] injected append failure on "${key}"`); } this.stats.appends++; let chunk = copyBuffer(data); if (this.faults.truncateAppendTo >= 0) { // 撕裂写:只落盘前 n 字节 chunk = copyBufferHead(chunk, this.faults.truncateAppendTo); this.faults.truncateAppendTo = -1; } // 追加目标是"已提交内容 + pending 中同 key 的写入"(同一 swap 文件内的续写) const base = this.pending.has(key) ? (this.pending.get(key) as ArrayBuffer | null) : this.committed.get(key) ?? null; if (!base) { this.pending.set(key, chunk); return; } const merged = new Uint8Array(base.byteLength + chunk.byteLength); merged.set(new Uint8Array(base), 0); merged.set(new Uint8Array(chunk), base.byteLength); this.pending.set(key, merged.buffer as ArrayBuffer); } /** 覆盖写(keepExistingData: false,真实 OPFS 会截断) */ async overwrite(key: string, data: ArrayBuffer): Promise { await this.write(key, data); } async delete(key: string): Promise { if (this.faults.failDelete > 0) { this.faults.failDelete--; throw new Error(`[OPFS-mock] injected delete failure on "${key}"`); } this.stats.deletes++; this.pending.set(key, null); // 删除也在提交时才生效 } async listKeys(): Promise { const keys = new Set(this.committed.keys()); for (const [key, value] of this.pending) { if (value === null) keys.delete(key); else keys.add(key); } return [...keys]; } async has(key: string): Promise { if (this.pending.has(key)) return this.pending.get(key) !== null; return this.committed.has(key); } /** 直接查看已提交字节数(不经过 read 的副本语义,供结构断言用) */ committedSize(key: string): number { return this.committed.get(key)?.byteLength ?? 0; } /** 已提交内容的只读快照(供"篡改磁盘"类测试:写坏字节后重开观察恢复) */ snapshotCommitted(): Map { const out = new Map(); for (const [k, v] of this.committed) out.set(k, copyBuffer(v)); return out; } /** 用快照替换已提交内容(模拟外部损坏/离线篡改) */ restoreCommitted(snapshot: Map): void { this.committed = new Map(); for (const [k, v] of snapshot) this.committed.set(k, copyBuffer(v)); this.pending.clear(); } /** 就地篡改已提交字节(模拟 bit flip / 部分覆盖),不做副本 */ corruptCommitted(key: string, mutate: (bytes: Uint8Array) => void): boolean { const buf = this.committed.get(key); if (!buf) return false; mutate(new Uint8Array(buf)); return true; } clear(): void { this.committed.clear(); this.pending.clear(); } } /** * 复制 ArrayBuffer(杜绝引用泄漏 —— 旧 mock 的缺陷 1) * * 注意:不能用 `buf.slice(0)` —— 在 jsdom 环境下 `ArrayBuffer.prototype.slice` 可能被 * Blob/File 的 slice 语义遮蔽,导致 `slice(0, n)` 的参数被忽略、返回整段内容。 * 这里用 Uint8Array 显式复制,行为在任何环境都确定。 */ function copyBuffer(buf: ArrayBuffer): ArrayBuffer { const out = new Uint8Array(buf.byteLength); out.set(new Uint8Array(buf)); return out.buffer as ArrayBuffer; } /** 复制前 n 字节(撕裂写用;同上不使用 ArrayBuffer.slice) */ function copyBufferHead(buf: ArrayBuffer, n: number): ArrayBuffer { const len = Math.max(0, Math.min(n, buf.byteLength)); const out = new Uint8Array(len); out.set(new Uint8Array(buf, 0, len)); return out.buffer as ArrayBuffer; } /** * 结构判别:是否为 `createWritable().write({type,position,data})` 形式。 * 不用 `instanceof ArrayBuffer` —— 跨 realm 时会误判(见 write 内注释)。 */ function isPositionedWrite( arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }, ): arg is { type: string; position: number; data: ArrayBuffer } { const anyArg = arg as { type?: unknown; position?: unknown; data?: unknown }; return typeof anyArg?.type === 'string' && typeof anyArg?.position === 'number' && anyArg?.data != null; } // --------------------------------------------------------------------------- // 第 2 层:OPFS 门面(供 OPFSBackend 使用) // --------------------------------------------------------------------------- /** 全局注册表:库名 → store(跨 backend 实例共享 = 持久化语义) */ const registry = new Map(); /** 取(或创建)某库的共享 store */ export function getStore(dbName: string): TransactionalFileStore { let store = registry.get(dbName); if (!store) { store = new TransactionalFileStore({ dbName }); registry.set(dbName, store); } return store; } /** 清空全局注册表(测试隔离) */ export function clearRegistry(): void { registry.clear(); } /** * 每次调用都重置的 OPFS 安装(推荐在 `beforeEach` 中使用)。 * * 等价于 `clearRegistry(); installOPFSMock(dbName);`。 * 命名里强调 reset 是为了避免旧写法的误导:此前测试写 `installOPFSMock(new Map())`, * 传入的 Map 会被 mock **静默忽略**(各测试文件各自传 Map 并不能隔离共享状态)。 */ export function resetOPFSMock(dbName = 'mock'): InstalledOPFSMock { clearRegistry(); return installOPFSMock(dbName); } export interface InstalledOPFSMock { store: TransactionalFileStore; /** * v0.8.0:取**某个库目录**的句柄 —— 测试与生产代码(`OPFSBackend.open`) * 使用同一个 API,因此"文件放在哪个目录"不可能出现两套理解。 * * 与之配套的 `listKeys/createFile/...` 是**根目录**视图(`dbName/文件` 形态), * 只适合"制造残留文件""看整体布局"这类场景;判断某个文件是否存在, * 应当用 `dir(dbName).hasFile(...)`。 */ dir(dbName: string): { listKeys(): Promise; createFile(name: string, content?: ArrayBuffer): Promise; hasFile(name: string): Promise; readFile(name: string): Promise; removeFile(name: string): Promise; }; /** 根目录下的所有文件(含库名前缀) */ listKeys(): Promise; /** 在**根目录**直接创建文件 —— 用于制造"崩溃残留临时文件"等场景 */ createFile(name: string, content?: ArrayBuffer): Promise; hasFile(name: string): Promise; readFile(name: string): Promise; removeFile(name: string): Promise; } /** * 安装 OPFS mock 到 globalThis.navigator.storage.getDirectory()。 * * @param dbName 库名(决定共享 store;同名多次安装拿到同一个 store) */ export function installOPFSMock(dbName = 'mock'): InstalledOPFSMock { const store = getStore(dbName); /** 文件路径 = `${目录名}/${文件名}`(root 目录的目录名为空串) */ const joinPath = (dirName: string, fileName: string): string => dirName ? `${dirName}/${fileName}` : fileName; /** * 构造某个目录的 FileSystemDirectoryHandle 视图。 * * v0.8.0:**目录名必须真正参与文件路径**。 * * `OPFSBackend.open(name)` 会 `root.getDirectoryHandle(name, {create:true})` * 并把返回的目录当作该库的根 —— 真实 OPFS 因此天然按库名隔离文件。 * 而此前的 mock 把这个 `name` **丢掉**(`async (_name) => dir`), * 所有库共用同一个扁平名字空间。实测(未修复时): * aroma.open('db-alpha') 建表 alpha_only → aroma.open('db-beta') * → `getTableNames()` 返回 ["alpha_only"](beta 看到了 alpha 的表) * 影响面:所有基于本 mock 的"多库/多租户/重启换名"场景都跑在错误语义上。 */ const makeDir = (dirName: string) => { const makeFileHandle = async (name: string, opts?: { create?: boolean }) => { const path = joinPath(dirName, name); if (!(await store.has(path)) && !opts?.create) { throw new Error(`NotFoundError: ${path}`); } return { /** * 真实 OPFS 的 getFile() 返回 File,读的是**已提交**内容(size 与 arrayBuffer 一致)。 * append 路径会读 `existing.size` 来定位追加位置,因此这里保持二者同源。 */ getFile: async () => { const content = (await store.read(path)) ?? new ArrayBuffer(0); return { size: content.byteLength, arrayBuffer: async () => content, }; }, createWritable: async (wOpts?: { keepExistingData?: boolean }) => { const keepExisting = wOpts?.keepExistingData ?? false; // OPFS 写语义:keepExistingData:false 时从空缓冲开始(旧 mock 会保留旧字节) let buffer = keepExisting ? (await store.read(path)) ?? new ArrayBuffer(0) : new ArrayBuffer(0); return { write: async (arg: ArrayBuffer | { type: string; position: number; data: ArrayBuffer }) => { // 注意:不能用 `arg instanceof ArrayBuffer` 判别 —— 跨 realm / 跨 Buffer 实现时 // 会失效(jsdom 与 Node 的 ArrayBuffer 可能不是同一个构造函数), // 从而把 ArrayBuffer 误当成 {position,data} 分支。改用结构判别。 if (!isPositionedWrite(arg)) { buffer = copyBuffer(arg as ArrayBuffer); return; } const chunk = arg; const end = chunk.position + chunk.data.byteLength; const merged = new Uint8Array(Math.max(end, buffer.byteLength)); merged.set(new Uint8Array(buffer), 0); merged.set(new Uint8Array(chunk.data), chunk.position); buffer = merged.buffer as ArrayBuffer; }, close: async () => { // 仅在 close 时提交 —— 未 close 的写入对读不可见(旧 mock 会立即可见) await store.write(path, buffer); store.commitAll(); }, }; }, }; }; return { getFileHandle: makeFileHandle, entries: async function* () { const prefix = dirName ? `${dirName}/` : ''; for (const key of await store.listKeys()) { if (!key.startsWith(prefix)) continue; const rest = key.slice(prefix.length); // 只列出本目录的**直接**子项(真实 OPFS 的 entries 语义) if (rest.includes('/')) continue; yield [rest]; } }, removeEntry: async (name: string) => { await store.delete(joinPath(dirName, name)); store.commitAll(); }, /** 子目录:`OPFSBackend.open(name)` 走这里 */ getDirectoryHandle: async (name: string, _opts?: unknown) => makeDir(joinPath(dirName, name)), }; }; const rootDir = makeDir(''); Object.defineProperty(globalThis, 'navigator', { value: { storage: { getDirectory: async () => rootDir, }, }, configurable: true, writable: true, }); /** * 构造"某个库目录"的测试视图。 * * 与生产代码同路径:`OPFSBackend.open(name)` 拿到的是 * `root.getDirectoryHandle(name)`,本函数走同一个入口。 */ const dirView = (name: string) => { const dirName = joinPath('', name); return { listKeys: async (): Promise => { const prefix = `${dirName}/`; return (await store.listKeys()) .filter((k) => k.startsWith(prefix) && !k.slice(prefix.length).includes('/')) .map((k) => k.slice(prefix.length)); }, createFile: async (fileName: string, content?: ArrayBuffer): Promise => { await store.write(joinPath(dirName, fileName), content ?? new ArrayBuffer(0)); store.commitAll(); }, hasFile: (fileName: string) => store.has(joinPath(dirName, fileName)), readFile: (fileName: string) => store.read(joinPath(dirName, fileName)), removeFile: async (fileName: string): Promise => { await store.delete(joinPath(dirName, fileName)); store.commitAll(); }, }; }; return { store, dir: dirView, // 辅助方法面向**根目录**(调用方给出的名字即完整相对路径,便于制造残留文件) listKeys: () => store.listKeys(), createFile: async (name, content) => { await store.write(name, content ?? new ArrayBuffer(0)); store.commitAll(); }, hasFile: (name) => store.has(name), readFile: (name) => store.read(name), removeFile: async (name) => { await store.delete(name); store.commitAll(); }, }; } /** * 直接由 TransactionalFileStore 驱动的可崩溃后端。 * * 用途:验证"真崩溃"(丢弃未提交写入)而非"优雅停机"(close 刷完队列)。 * OPFSBackend 之上无法表达 pending 语义(它每次 write 都会 close 提交), * 因此需要这一层直连介质的后端来构造"写入进行中崩溃"的窗口。 */ export class CrashableStoreBackend implements IStorageBackend { private readonly store: TransactionalFileStore; constructor(store: TransactionalFileStore) { this.store = store; } open(_name: string): Promise { return Promise.resolve(); } close(): Promise { return Promise.resolve(); } isOpen(): boolean { return true; } read(key: string) { return this.store.read(key); } write(key: string, data: ArrayBuffer) { return this.store.write(key, data); } append(key: string, data: ArrayBuffer) { return this.store.append(key, data); } writeMany(entries: Record) { return (async () => { for (const [k, v] of Object.entries(entries)) await this.store.write(k, v); })(); } delete(key: string) { return this.store.delete(key); } deleteMany(keys: string[]) { return (async () => { for (const k of keys) await this.store.delete(k); })(); } listKeys() { return this.store.listKeys(); } exists(key: string) { return this.store.has(key); } clear(): Promise { this.store.clear(); return Promise.resolve(); } /** 崩溃:丢弃全部未提交写入 */ simulateCrash(): void { this.store.crashPending(); } /** 正常提交(模拟所有 createWritable 完成 close) */ commit(): void { this.store.commitAll(); } }