Files
MetonaSqlark/tests/v042-fixes.test.ts
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

464 lines
20 KiB
TypeScript
Raw Permalink 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.
/**
* v0.4.2 问题清单回归测试
* 覆盖:
* - P0-1: SSTable 残缺数据防御(读路径越界跳过 / 打开时完整性校验 / WAL 原子写入)
* - P0-2: IndexedDB 版本管理(VersionError 自适应重开)
* - P0-3: version 0 归一化
* - P1-4: WAL 写丢失(原子 append + 按 key 扫描恢复)
* - P1-5: close 截断 WAL(不无限重放)
* - P1-6: 引擎内部错误包装 DatabaseError
* - P2-7: 迁移版本持久化
* - P2-9: repair() / clearAll() 统一自愈接口
*/
import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
import { SSTableBuilder } from '../src/engine/aria/index/sstable_builder';
import { SSTableReader } from '../src/engine/aria/index/sstable';
import { MetonaSqlark } from '../src/core';
import { OPFSBackend } from '../src/engine/aria/store/opfs_backend';
import { KVStoreEngine } from '../src/engine/kvstore_engine';
import type { SSTableMeta } from '../src/engine/aria/types';
import { resetOPFSMock, readManifestNamespace } from './helpers/storage-harness';
beforeEach(() => { resetOPFSMock(); });
let idbCounter = 0;
function uniqueDB(): string {
return `fix-${Date.now()}-${++idbCounter}-${Math.random().toString(36).slice(2, 8)}`;
}
const makeMeta = (data: Uint8Array): SSTableMeta => ({
id: 1, level: 0, minKey: '', maxKey: '\uffff',
blockCount: 1, totalSize: data.byteLength, bloomData: null,
});
// ===================================================================
// P0-1a: SSTableReader 残缺数据防御
// ===================================================================
describe('P0-1a — SSTableReader 残缺数据防御', () => {
it('索引块越界(文件被截断)→ 构造不抛异常,get/rangeScan/scanAll 返回空', () => {
const builder = new SSTableBuilder(64);
for (let i = 0; i < 100; i++) {
builder.add(`k-${String(i).padStart(3, '0')}`, { v: i, data: 'x'.repeat(30) });
}
const { sstableData } = builder.build();
// 完整文件可用(对照)
const full = new SSTableReader(sstableData, makeMeta(sstableData));
expect(full.get('k-001')).not.toBeNull();
// 从索引块中部截断(手动拼接保留末尾 32 字节 footer,使索引块越界但 footer 完整)
const footerOffset = sstableData.byteLength - 32;
const indexOffset = new DataView(sstableData.buffer, sstableData.byteOffset, sstableData.byteLength)
.getUint32(footerOffset, false);
const truncated = new Uint8Array((indexOffset + 4) + 32);
truncated.set(sstableData.slice(0, indexOffset + 4), 0);
truncated.set(sstableData.slice(sstableData.byteLength - 32), indexOffset + 4);
// 构造必须不抛 RangeError
let reader: SSTableReader | undefined;
expect(() => { reader = new SSTableReader(truncated, makeMeta(truncated)); }).not.toThrow();
if (!reader) throw new Error('SSTableReader 构造未成功');
const r = reader;
expect(() => (r as any).get('k-001')).not.toThrow();
expect(() => (r as any).rangeScan('a', 'z', () => {})).not.toThrow();
expect(() => (r as any).scanAll(() => {})).not.toThrow();
expect((r as any).get('k-001')).toBeNull();
});
it('索引条目 blockSize 越界 → 该块跳过,其余块仍可读', () => {
const builder = new SSTableBuilder(64);
for (let i = 0; i < 100; i++) {
builder.add(`k-${String(i).padStart(3, '0')}`, { v: i, data: 'x'.repeat(30) });
}
const { sstableData } = builder.build();
const corrupted = sstableData.slice();
const view = new DataView(corrupted.buffer, corrupted.byteOffset, corrupted.byteLength);
const footerOffset = corrupted.byteLength - 32;
const indexOffset = view.getUint32(footerOffset, false);
expect(view.getUint32(indexOffset, false)).toBeGreaterThan(1);
// 把第二个索引条目的 blockSize 改为超大(指向文件外):
// 跳过 entry0keyLen+key+blockOffset+blockSize)与 entry1 的 keyLen+key+blockOffset
let off = indexOffset + 4;
const keyLen0 = view.getUint16(off, false);
off += 2 + keyLen0 + 8;
const keyLen1 = view.getUint16(off, false);
off += 2 + keyLen1 + 4;
view.setUint32(off, 0x7FFFFFF0, false);
const reader = new SSTableReader(corrupted, makeMeta(corrupted));
// 不抛 RangeError
expect(() => reader.get('k-001')).not.toThrow();
expect(() => reader.rangeScan('a', 'z', () => {})).not.toThrow();
expect(() => reader.scanAll(() => {})).not.toThrow();
// 第一个索引条目(未破坏)指向的块仍可读
const first = reader.get('k-000');
expect(first).not.toBeNull();
expect((first as any).v).toBe(0);
// 被破坏的块被跳过(返回 null 而非崩溃)
expect(reader.get('k-001')).toBeNull();
});
it('魔数错误 → 构造抛错(由 LSM loadSSTableReader 捕获跳过)', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
const { sstableData } = builder.build();
const corrupted = sstableData.slice();
// 破坏 footer 中 magic(位于文件末尾 32 字节内的 +24 偏移)
new DataView(corrupted.buffer, corrupted.byteOffset, corrupted.byteLength)
.setUint32(corrupted.byteLength - 8, 0xDEADBEEF, false);
expect(() => new SSTableReader(corrupted, makeMeta(corrupted))).toThrow();
});
it('块内条目计数虚高(内容截断)→ 提前中止,不抛 RangeError', () => {
const builder = new SSTableBuilder(64);
for (let i = 0; i < 100; i++) {
builder.add(`k-${String(i).padStart(3, '0')}`, { v: i, data: 'x'.repeat(30) });
}
const { sstableData } = builder.build();
const corrupted = sstableData.slice();
// 把第一个数据块的 entryCount 改为超大(模拟块内条目被截断)
new DataView(corrupted.buffer, corrupted.byteOffset, corrupted.byteLength)
.setUint32(0, 0x7FFFFFF0, false);
expect(() => new SSTableReader(corrupted, makeMeta(corrupted))).not.toThrow();
const reader = new SSTableReader(corrupted, makeMeta(corrupted));
expect(() => reader.get('k-000')).not.toThrow();
expect(() => reader.get('nonexistent')).not.toThrow();
expect(() => reader.scanAll(() => {})).not.toThrow();
// 截断点之前的条目仍可读
expect((reader.get('k-000') as any)?.v).toBe(0);
});
});
// ===================================================================
// P0-1b: LSM 打开时完整性校验
// ===================================================================
describe('P0-1b — AriaEngine 打开时完整性校验', () => {
it('meta 引用残缺文件 → 打开跳过损坏 SSTable 不崩溃,库可用', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
for (let i = 0; i < 50; i++) {
await engine.insert('users', [{ id: `u${i}`, name: `User${i}` }]);
}
await (engine as any).lsm.flush();
await engine.close();
// 篡改存储:把第一个 SSTable 的第一个页面写成残缺内容(meta 仍引用它)
const backend = new OPFSBackend();
await backend.open(dbName);
// v0.8.0B-6):SSTable meta 随 manifest 原子提交(不再是裸 JSON key)
const metas = await readManifestNamespace(backend, 'main') as { id: number; pageIds: number[] }[];
expect(metas.length).toBeGreaterThan(0);
const pageId = metas[0].pageIds[0];
await backend.write(`pg_${pageId}`, new TextEncoder().encode('truncated-garbage').buffer);
await backend.close();
// 重开:不得崩溃(此前抛 RangeError 打不开库)
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
expect(engine2.isOpen()).toBe(true);
// schema 完整,损坏的 SSTable 已被清理
expect(await engine2.getTableNames()).toEqual(['users']);
const rows = await engine2.find('users', { table: 'users' });
expect(Array.isArray(rows)).toBe(true);
await engine2.close();
// 清理 meta 已验证:损坏文件被删除
const backend2 = new OPFSBackend();
await backend2.open(dbName);
const remaining = (await backend2.listKeys()).filter((k) => k.startsWith('pg_'));
const metaList = await readManifestNamespace(backend2, 'main');
const livePages = new Set<number>();
for (const m of metaList) if (m.pageIds) for (const pid of m.pageIds) livePages.add(pid);
const orphan = remaining.filter((k) => !livePages.has(Number(k.slice(3))));
expect(orphan.length).toBe(0); // 无孤儿页面
await backend2.close();
});
it('meta 引用缺失页面 → 打开清理 meta 不崩溃', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
}));
for (let i = 0; i < 30; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
await (engine as any).lsm.flush();
await engine.close();
// 删除全部 pg_ 页面(保留 meta → 悬空引用)
const backend = new OPFSBackend();
await backend.open(dbName);
const pgKeys = (await backend.listKeys()).filter((k) => k.startsWith('pg_'));
await backend.deleteMany(pgKeys);
await backend.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
expect(engine2.isOpen()).toBe(true);
expect(await engine2.getTableNames()).toEqual(['t']);
await engine2.close();
});
});
// ===================================================================
// P0-1c / P1-4: WAL 原子写入 + 按 key 扫描恢复
// ===================================================================
describe('P0-1c / P1-4 — WAL 原子性与恢复', () => {
it('OPFSBackend.writeMany/deleteMany 批量原子操作', async () => {
const dbName = uniqueDB();
const be = new OPFSBackend();
await be.open(dbName);
await be.writeMany({
a: new TextEncoder().encode('1').buffer,
b: new TextEncoder().encode('2').buffer,
});
expect(new TextDecoder().decode((await be.read('a'))!)).toBe('1');
expect(new TextDecoder().decode((await be.read('b'))!)).toBe('2');
await be.deleteMany(['a', 'b']);
expect(await be.read('a')).toBeNull();
expect(await be.read('b')).toBeNull();
await be.close();
});
it('P1-4: 连续快速写入 200 条 → close+重开 数据完整(实测曾丢 4 条)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
}));
for (let i = 0; i < 200; i++) {
await engine.insert('t', [{ id: `${i}`, v: i }]);
}
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine2.open(dbName, 1);
expect(await engine2.count('t')).toBe(200);
await engine2.close();
});
it('P1-4: count 键丢失(模拟崩溃竞态)→ 按 key 扫描恢复不丢记录', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
}));
for (let i = 0; i < 20; i++) {
await engine.insert('t', [{ id: `${i}`, v: i }]);
}
// 模拟异常退出(不 close):直接删掉 count 键,制造 count 与记录不一致
const backend = new OPFSBackend();
await backend.open(dbName);
await backend.delete('__wal_count');
await backend.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine2.open(dbName, 1);
expect(await engine2.count('t')).toBe(20);
await engine2.close();
});
});
// ===================================================================
// P1-5: close 截断 WAL
// ===================================================================
describe('P1-5 — AriaEngine.close 截断 WAL', () => {
it('close 后 WAL 记录键全部清空(不无限重放)', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', walSyncMode: 'full' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
}));
await engine.insert('t', [{ id: '1', v: 1 }, { id: '2', v: 2 }]);
await engine.close();
const backend = new OPFSBackend();
await backend.open(dbName);
// __wal_count 计数键合法保留(作为 append 序号分配器),WAL 记录键必须清空
const walKeys = (await backend.listKeys())
.filter((k) => k.startsWith('__wal_') && k !== '__wal_count');
expect(walKeys).toHaveLength(0);
await backend.close();
});
});
// ===================================================================
// P1-6: 引擎错误包装 DatabaseError
// ===================================================================
describe('P1-6 — 引擎内部错误统一包装 DatabaseError', () => {
it('AriaEngine open 异常路径抛 DatabaseErrorARIA_OPEN_ERROR', async () => {
// 构造一个无法打开的 backend 场景:直接调用 openInternal 模拟底层异常不可行,
// 这里验证损坏库重开时抛的是 DatabaseError 而非原生错误(不崩溃路径已由 P0-1b 覆盖)
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs' });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await engine.insert('t', [{ id: '1' }]);
await engine.close();
const engine2 = new AriaEngine({ storageBackend: 'opfs' });
await expect(engine2.open(dbName, 1)).resolves.toBeUndefined();
expect(engine2.isOpen()).toBe(true);
await engine2.close();
});
it('底层介质故障时抛 DatabaseError 而非原生错误', async () => {
const failing = {
open: async () => { throw new Error('disk failure'); },
close: async () => {},
isOpen: () => false,
read: async () => null,
write: async () => { throw new Error('disk failure'); },
append: async () => {},
writeMany: async () => {},
delete: async () => {},
deleteMany: async () => {},
listKeys: async () => [],
exists: async () => false,
clear: async () => {},
};
const engine = new KVStoreEngine(failing as never);
// 介质故障如实传播(不吞错),AriaEngine 路径由 ARIA_OPEN_ERROR 包装(上方测试覆盖)
await expect(engine.open(uniqueDB(), 1)).rejects.toThrow('disk failure');
});
});
// ===================================================================
// P2-7: 迁移版本持久化
// ===================================================================
describe('P2-7 — 迁移版本持久化', () => {
it('重启后已执行迁移不重跑,只执行新迁移', async () => {
const dbName = uniqueDB();
const runs: number[] = [];
// version: 0 表示"无 schema 起点"migration 1 可执行
const db1 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs', version: 0 });
await db1.init();
db1.addMigration(1, async () => { runs.push(1); });
db1.addMigration(2, async () => { runs.push(2); });
await db1.migrateTo(2);
expect(runs).toEqual([1, 2]);
await db1.close();
// 重启:version 又重置为 config.version=0(此前会重跑 migration 1/2
const db2 = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs', version: 0 });
await db2.init();
db2.addMigration(1, async () => { runs.push(1); });
db2.addMigration(2, async () => { runs.push(2); });
db2.addMigration(3, async () => { runs.push(3); });
await db2.migrateTo(3);
// 只有 migration 3 是新迁移(1/2 已持久化不重跑)
expect(runs).toEqual([1, 2, 3]);
await db2.close();
});
it('AriaEngine getMeta/setMeta 往返', async () => {
const engine = new AriaEngine({ storageBackend: 'memory' });
await engine.open(uniqueDB(), 1);
expect(await engine.getMeta('__metona_version')).toBeNull();
await engine.setMeta('__metona_version', '3');
expect(await engine.getMeta('__metona_version')).toBe('3');
await engine.close();
});
});
// ===================================================================
// P2-9: repair() / clearAll() 统一自愈接口
// ===================================================================
describe('P2-9 — 统一自愈接口 repair / clearAll', () => {
it('AriaEngine.clearAll 清空全部表与数据', async () => {
const engine = new AriaEngine({ storageBackend: 'memory' });
await engine.open(uniqueDB(), 1);
await engine.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await engine.insert('t', [{ id: '1' }]);
await engine.clearAll();
expect(await engine.getTableNames()).toEqual([]);
await engine.close();
});
it('AriaEngine.repair 清理损坏 SSTable 后引擎可用', async () => {
const dbName = uniqueDB();
const engine = new AriaEngine({ storageBackend: 'opfs', memtableSizeThreshold: 64 * 1024 * 1024 });
await engine.open(dbName, 1);
await engine.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
}));
for (let i = 0; i < 50; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
await (engine as any).lsm.flush();
for (let i = 50; i < 100; i++) await engine.insert('t', [{ id: `k${i}`, v: i }]);
await (engine as any).lsm.flush();
// 篡改一个 SSTable 的页面文件
const backend = new OPFSBackend();
await backend.open(dbName);
const metas = await readManifestNamespace(backend, 'main') as { id: number; pageIds: number[] }[];
expect(metas.length).toBeGreaterThan(0);
const victimPage = metas[0].pageIds[0];
const raw = new Uint8Array(await backend.read(`pg_${victimPage}`) as ArrayBuffer);
raw[50] ^= 0xff;
await backend.write(`pg_${victimPage}`, raw.buffer as ArrayBuffer);
await backend.close();
// repair:校验清理损坏 SSTable,其余数据可用
await (engine as any).repair();
expect(await engine.count('t')).toBeGreaterThan(0);
expect(await engine.count('t')).toBeLessThan(100);
await engine.close();
});
it('MetonaSqlark.clearAll 统一接口(hybrid', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.table('t').insert({ id: '1' });
await db.clearAll();
expect(await db.getTableNames()).toEqual([]);
await db.close();
});
it('MetonaSqlark.repair 统一接口(aria', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'aria', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
await db.table('t').insert({ id: '1', v: 42 });
await db.repair();
expect(await db.table('t').count()).toBe(1);
await db.close();
});
it('MetonaSqlark.repair 统一接口(hybrid', async () => {
const dbName = uniqueDB();
const db = new MetonaSqlark({ name: dbName, mode: 'hybrid', diskEngine: 'opfs' });
await db.init();
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.table('t').insert({ id: '1' });
await db.repair();
expect(await db.table('t').count()).toBe(1);
await db.close();
});
});