删除: - AUDIT-aria-lsm-v0.7.4.md(50KB) - AUDIT-query-layer-v0.7.4.md(39KB) - AUDIT-storage-engines-v0.7.4.md(39KB) - PLAN-v0.7.5.md(98KB,含附录 G/H/I) 删除前先清除引用面,避免留下断链(共 18 处): - 源码注释 7 处(change-notifier / kvstore index / column-value / expression / sql-compare / where-matcher / validation):保留设计意图,引用改为"v0.8.0 审计根因 N" - 测试注释 9 处(opfs.spec / aria-opfs-backend / faulty-backend / storage-harness / v080-b6 / v080-kvstore / v080-query-layer / v080-sql-three-valued / v080-unified-validation / parser):同上 - CHANGELOG 3 处:改为不依赖已删除文档的自洽表述(B-6 交付物见各条;门禁订正三处 按内容重写),并把变异数量同步为 42 - 校验:三个 md 之间无断链;仓库内已无 PLAN-v0.7.5/AUDIT-* 的任何引用 (git 历史仍可追溯,需要时可 `git show <commit>:PLAN-v0.7.5.md` 找回) 验证:93 套件 / 1985 用例全绿;覆盖率 90.59 / 82.61 / 94.14 / 93.50(阈值 90/82/94/93); e2e 14/14;lint + 两份 tsc 干净;dist 已重建(注释只影响非压缩产物,min 产物 251,731 B / gzip 63,431 B 不变)。 说明:审查记录的核心内容仍在 CHANGELOG.md("全量回归审查"与"现场失败修复"两节), 随 PLAN 一起删除的是附录 G/H/I 的详细表格(门禁逐条验收、交付物清单、未修复项表)。
554 lines
22 KiB
TypeScript
554 lines
22 KiB
TypeScript
/**
|
||
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
|
||
*
|
||
* ============================================================================
|
||
* 为什么需要重写(审计结论,见 v0.8.0 迭代工作流 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<string, ArrayBuffer>();
|
||
/** 未提交写入(= OPFS createWritable 的 swap 区) */
|
||
private pending = new Map<string, ArrayBuffer | null>();
|
||
|
||
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<ArrayBuffer | null> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
await this.write(key, data);
|
||
}
|
||
|
||
async delete(key: string): Promise<void> {
|
||
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<string[]> {
|
||
const keys = new Set<string>(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<boolean> {
|
||
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<string, ArrayBuffer> {
|
||
const out = new Map<string, ArrayBuffer>();
|
||
for (const [k, v] of this.committed) out.set(k, copyBuffer(v));
|
||
return out;
|
||
}
|
||
|
||
/** 用快照替换已提交内容(模拟外部损坏/离线篡改) */
|
||
restoreCommitted(snapshot: Map<string, ArrayBuffer>): 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<string, TransactionalFileStore>();
|
||
|
||
/** 取(或创建)某库的共享 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<string[]>;
|
||
createFile(name: string, content?: ArrayBuffer): Promise<void>;
|
||
hasFile(name: string): Promise<boolean>;
|
||
readFile(name: string): Promise<ArrayBuffer | null>;
|
||
removeFile(name: string): Promise<void>;
|
||
};
|
||
/** 根目录下的所有文件(含库名前缀) */
|
||
listKeys(): Promise<string[]>;
|
||
/** 在**根目录**直接创建文件 —— 用于制造"崩溃残留临时文件"等场景 */
|
||
createFile(name: string, content?: ArrayBuffer): Promise<void>;
|
||
hasFile(name: string): Promise<boolean>;
|
||
readFile(name: string): Promise<ArrayBuffer | null>;
|
||
removeFile(name: string): Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* 安装 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<string[]> => {
|
||
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<void> => {
|
||
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<void> => {
|
||
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<void> { return Promise.resolve(); }
|
||
close(): Promise<void> { 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<string, ArrayBuffer>) {
|
||
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<void> { this.store.clear(); return Promise.resolve(); }
|
||
|
||
/** 崩溃:丢弃全部未提交写入 */
|
||
simulateCrash(): void {
|
||
this.store.crashPending();
|
||
}
|
||
|
||
/** 正常提交(模拟所有 createWritable 完成 close) */
|
||
commit(): void {
|
||
this.store.commitAll();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// v0.8.0(B-6):manifest 读取辅助
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 读取存储上**最新有效世代**的 manifest(用生产解码器,不做测试特供路径)。
|
||
*
|
||
* 为什么测试需要它:v0.8.0 起 SSTable 元数据与表结构不再是各自独立的裸 JSON
|
||
* (`__aria_lsm_meta*` / `__aria_schemas`),而是随 manifest 一起原子提交。
|
||
* 任何"直接读裸 JSON 验证落盘内容"的测试都必须改读 manifest —— 否则它验证的是
|
||
* 一个已经不存在(且没有 CRC/世代保护)的存储布局。
|
||
*
|
||
* @returns manifest 内容;全新库(没有任何 manifest 文件)返回 null;
|
||
* 文件存在但全部世代无效 → 抛错(与生产恢复语义一致,绝不"失败当空库")
|
||
*/
|
||
export async function readManifestState(
|
||
backend: IStorageBackend,
|
||
): Promise<import('../../src/engine/aria/store/manifest').AriaManifest | null> {
|
||
const { ManifestStore } = await import('../../src/engine/aria/store/manifest');
|
||
const store = new ManifestStore({ backend });
|
||
const loaded = await store.load();
|
||
return loaded.manifest;
|
||
}
|
||
|
||
/** 读取某个命名空间当前的 SSTable meta 列表(测试断言用) */
|
||
export async function readManifestNamespace(
|
||
backend: IStorageBackend,
|
||
ns: string = 'main',
|
||
): Promise<{ id: number; level: number; pageIds?: number[]; minKey: string; maxKey: string }[]> {
|
||
const manifest = await readManifestState(backend);
|
||
return manifest?.namespaces[ns]?.sstables ?? [];
|
||
}
|