Files
MetonaSqlark/tests/helpers/storage-harness.ts
T
thzxx c5694b1d23 feat(B-6): 存储层单一提交点(__aria_manifest)+ LSM 结构根治
按 PLAN-v0.7.5.md §B-6 的**完整规格**实施(此前只落地了"降级选项"里的五处止血):
B-6 要求的是 `__aria_manifest` 单一提交点 + LSM 单项改造。完整记录见方案附录 H。

一、单一提交点
  - 新增 `src/engine/aria/store/manifest.ts`:`__aria_manifest_<generation>`
    (magic + formatVersion + generation + 头部 CRC + 载荷 CRC;先写后验;保留两代)。
    载荷 = 页面水位 + 各命名空间 SSTable 元数据 + 表结构 + WAL 起始位置 + 待落盘冻结表意图。
  - 顺序固定:**数据落盘 → manifest 提交 → 才允许截断 WAL / 删除旧文件 / 删除旧 SSTable**。
  - 恢复只认最后一份 CRC 通过的世代;全部世代无效 → `ARIA_MANIFEST_CORRUPT`
    (修复前:裸 JSON meta 解析失败 → `[]` → 静默空库,随后 repair 还会删光活页)。
  - 旧格式(__aria_lsm_meta/__aria_schemas/__aria_meta)首次打开自动迁移,旧键保留;
    迁移遇到损坏 → `ARIA_LEGACY_META_CORRUPT`。
  - 陈旧实例保护(STALE_INSTANCE):认领时一次跨过 MANIFEST_TAKEOVER_STRIDE 个世代,
    杜绝"旧实例在途提交落在同一世代号上"(实测第二个实例 open 直接失败)。

二、LSM
  - 44 冻结表成为一等状态:失败保留 + 可重试(修复前失败即永久失去落盘机会)。
  - 45 `flush()` 先入链再报告后台错误(修复前一次后台失败会让之后每次 flush 直接抛错、
       数据永远等不到落盘);被重试修复的失败进 `getBackgroundWarnings()`(可见但不误报失败)。
  - 47 `MergeIterator` 胜出来源的补充推迟到下一次 `next()`:提前终止不再多算一条。
  - 49 `compacting` 由单 boolean 改为按层集合(跨层触发不再被静默丢弃)。
  - 50 compaction 不再"先 splice 整层再合并"(窗口内该层对读者可见);
       被取代的 SSTable 进"退休表" + 读者 epoch,等更早读者退出才物理删除。
  - 51 底部层原地合并回收墓碑(删除密集场景空间不再无界增长);"整层只剩墓碑" 有专门分支
       (修复前会读 `merged[0][0]` 抛 TypeError,compaction 永久失败)。
  - 55 flush 与 compaction 拆成两条链,checkpoint 只落 memtable;删除引擎层全部
       `prefetch*`/`drainChain` 依赖,改为"快照 + 结构版本乐观重试"
       (版本号同时覆盖 levels 与前台 memtable/frozen 的变化)。
  - 读路径自洽:介质读故障抛 `ARIA_SSTABLE_READ_FAILED`,不再折叠成"文件不存在"误删元数据。

三、WAL
  - LSN 全库单调(manifest 记高水位);按水位删除旧分片(`planKeepFrom` → 提交 → 再删除)。
  - **分片号只增不减**:修复前全量截断后重置为 0,会与 manifest 记录的 startSegment 错位,
    实测造成两个方向的损坏(删掉的行复活 / 已确认写入丢失,见随机压力套件)。
  - 分片空洞(含前缀缺失)显式报 `ARIA_WAL_GAP`,不再静默丢弃尾部。

四、其它
  - `sstable.ts` 三份解析循环合并为 `iterEntries()`,越界策略统一。
  - `vacuum()` 返回真实压缩层数(修复前硬编码 6 且底部层永不压缩)。
  - `close()` 加 try/finally(落盘失败也必须释放后端/锁并复位状态)。
  - `getRecoveryReport()`:{droppedSSTables, dataLossSuspected, walGaps, legacyImported,
    manifestFallback} —— "自愈了什么、有没有真丢数据"成为可读返回值。

五、验证
  - 新增 `tests/v080-b6-single-commit-point.test.ts`(63 项,含 manifest 严格校验表驱动 25 例)。
  - 新增 `scripts/mutation-b6.py`:22 项变异验证(把每个修复回退到修复前行为,对应用例必须失败),
    全部被拦住 —— 这批用例不是陪跑。
  - 常规套件 1935 通过 / 91 套件;覆盖率 90.34 / 82.16 / 94.06 / 93.23(阈值 90/82/94/93);
    e2e 14/14;重型套件 4 套件 27 项全绿。
2026-09-15 10:29:03 +08:00

554 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 测试共享 — OPFS 事务性文件存储(v0.8.0 根治版)
*
* ============================================================================
* 为什么需要重写(审计结论,见 PLAN-v0.7.5.md 工作流 C-1
* ============================================================================
* 旧 mocktests/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.0B-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 ?? [];
}