kimi-code / packages /minidb /test /generation.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
4e23b01 verified
Raw History Blame Contribute Delete
67.7 kB
// test/generation.test.ts
//
// Persistent index generations (stage 5): unit tests for the codecs and the
// skiplist bulk loader, integration tests for the build → publish → load
// cycle, and the crash/corruption fault matrix. The fault-injection tests
// patch the shared node:fs/promises default export (all source modules
// consume it through a default import, so the patch lands everywhere) with
// path-predicated wrappers, plus the barrier facility from helpers.ts for
// deterministic mid-build crash points.
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { MiniDb } from '../src/index.js';
import { TextIndex } from '../src/text-index/index.js';
import { SkipList, cmpNumber, cmpString } from '../src/skiplist.js';
import {
GenerationCorruptError,
parseGenerationBuffer,
readStoreImage,
readDtIndexImage,
readSecondaryIndexImage,
readCompoundIndexImage,
readTextDocsImage,
readSecondaryIndexImageAsync,
readCompoundIndexImageAsync,
readTextDocsImageAsync,
verifyFileIntegritySync,
verifyFileIntegrityAsync,
writeStoreImage,
writeDtIndexImage,
writeSecondaryIndexImage,
writeCompoundIndexImage,
writeTextDocsImage,
} from '../src/gen-codec.js';
import type { StoreImageRecord } from '../src/gen-codec.js';
import { crc32 } from '../src/crc32.js';
import { Store } from '../src/store.js';
import { IndexManager } from '../src/index-manager.js';
import { CompoundIndexManager } from '../src/compound-index.js';
import { readManifest, listGenerations } from '../src/generation-files.js';
import { tmpDir, rmrf, waitFor, deferred } from './helpers.js';
const cleanups: (() => Promise<void> | void)[] = [];
afterEach(async () => {
while (cleanups.length) await cleanups.pop()!();
});
async function openTmp(name: string): Promise<string> {
const dir = await tmpDir(`minidb-gen-${name}-`);
cleanups.push(() => rmrf(dir));
return dir;
}
type AnyDb = MiniDb<unknown>;
async function closeAll(...dbs: (AnyDb | undefined)[]): Promise<void> {
for (const db of dbs) await db?.close().catch(() => {});
}
/** Patch fs.open so handles for paths matching `pred` get their writev/sync
* replaced by `fail` (once). Returns a restore function. */
function failFileWrites(pred: (p: string) => boolean, fail: 'writev' | 'sync', error: unknown): () => void {
const original = fs.open;
let fired = false;
(fs as unknown as Record<string, unknown>).open = async (p: fsSync.PathLike, flags?: string) => {
const handle = await original(p as string, flags as string);
const s = String(p);
if (!fired && pred(s)) {
fired = true;
(handle as unknown as Record<string, unknown>)[fail] = () => Promise.reject(error);
}
return handle;
};
return () => {
(fs as unknown as Record<string, unknown>).open = original;
};
}
/** Patch fs.rename, failing calls whose (src,dst) pair matches. */
function failRenames(pred: (src: string, dst: string) => boolean, error: unknown): () => void {
const original = fs.rename;
(fs as unknown as Record<string, unknown>).rename = (src: string, dst: string) =>
pred(String(src), String(dst)) ? Promise.reject(error) : original(src, dst);
return () => {
(fs as unknown as Record<string, unknown>).rename = original;
};
}
/** Populate a db with one of every index family and a body of docs. */
async function seedIndexedDb(db: MiniDb<Record<string, unknown>>, n = 2000): Promise<void> {
await db.createIndex('byKind', { field: 'kind' });
await db.createIndex('byScore', { field: 'score', type: 'range' });
await db.createCompoundIndex('byKindTime', { groupBy: 'kind', orderBy: 'ts' });
await db.createTextIndex('ft', { fields: ['text'] });
await db.createTextIndex('tri', { fields: ['text'], tokenizer: 'ngram' });
for (let i = 0; i < n; i++) {
await db.set(
`k${i}`,
{ kind: `t${i % 7}`, score: i % 100, ts: 1700000000000 + i, text: `hello world doc ${i} 持久化 索引 ${i % 13}` },
{ dt: { ts: 1700000000000 + i } },
);
}
}
/** The exact assertions a correctly-loaded seeded db must satisfy. `size`
* defaults to n but callers that added/removed keys after seeding pass the
* expected live count explicitly; `dtTail` likewise covers the dt-window
* assertion when extra keys carry newer dt values. */
function assertSeededDb(db: MiniDb<Record<string, unknown>>, n = 2000, size = n, dtTail = 10, k0Present = true): void {
const eqT3 = [...Array(n)].filter((_, i) => i % 7 === 3).length;
expect(db.size).toBe(size);
if (k0Present) expect(db.get('k0')).toMatchObject({ kind: 't0', score: 0 });
expect(db.get(`k${n - 1}`)).toMatchObject({ kind: `t${(n - 1) % 7}` });
expect(db.findEq('byKind', 't3').length).toBe(eqT3);
expect(db.findRange('byScore', { min: 42, max: 42 }).length).toBe(Math.floor(n / 100) + (n % 100 > 42 ? 1 : 0));
expect(db.compoundRange('byKindTime', 't2', { limit: 5 }).length).toBe(5);
expect(db.dtRange('ts', { gte: 1700000000000 + n - 10 }).length).toBe(dtTail);
expect(db.search('ft', '持久化').length).toBeGreaterThan(0);
expect(db.search('tri', 'hello world').length).toBeGreaterThan(0);
}
// ---- unit: skiplist bulk load ----------------------------------------------
describe('skiplist bulkLoad', () => {
test('matches one-by-one inserts on content, rank, and range behavior', () => {
const entries: { key: number; val: string }[] = [];
for (let i = 0; i < 5000; i++) entries.push({ key: i * 2, val: `v${i}` });
const bulk = SkipList.bulkLoad(entries, { compareKey: cmpNumber, compareVal: cmpString });
const inc = new SkipList<number, string>({ compareKey: cmpNumber, compareVal: cmpString });
for (const e of entries) inc.insert(e.key, e.val);
expect(bulk.length).toBe(inc.length);
expect(bulk.toArray()).toEqual(inc.toArray());
for (const probe of [0, 1, 42, 2499, 4999]) {
const e = entries[probe]!;
expect(bulk.getRank(e.key, e.val)).toBe(inc.getRank(e.key, e.val));
}
expect(bulk.getByRank(0)).toEqual(inc.getByRank(0));
expect(bulk.getByRank(4999)).toEqual(inc.getByRank(4999));
expect(bulk.range({ gte: 100, lte: 200, count: 7 })).toEqual(inc.range({ gte: 100, lte: 200, count: 7 }));
expect([...bulk.iterate({ reverse: true, count: 5 })]).toEqual([...inc.iterate({ reverse: true, count: 5 })]);
// Mutations after the bulk load keep every invariant.
bulk.insert(3, 'v-new');
bulk.delete(100, 'v50');
inc.insert(3, 'v-new');
inc.delete(100, 'v50');
expect(bulk.toArray()).toEqual(inc.toArray());
expect(bulk.getRank(3, 'v-new')).toBe(inc.getRank(3, 'v-new'));
expect(bulk.getByRank(1234)).toEqual(inc.getByRank(1234));
});
test('dedupes exact (key, val) duplicates like insert would', () => {
const bulk = SkipList.bulkLoad(
[
{ key: 1, val: 'a' },
{ key: 1, val: 'a' },
{ key: 1, val: 'b' },
],
{ compareKey: cmpNumber, compareVal: cmpString },
);
expect(bulk.length).toBe(2);
expect(bulk.toArray()).toEqual([
{ key: 1, val: 'a' },
{ key: 1, val: 'b' },
]);
});
test('bulkLoadAsync builds the identical tower, sliced (incl. duplicates)', async () => {
const entries: { key: number; val: string }[] = [];
for (let i = 0; i < 5000; i++) {
entries.push({ key: i * 2, val: `v${i}` });
if (i % 997 === 0) entries.push({ key: i * 2, val: `v${i}` }); // exact duplicates
}
const sync = SkipList.bulkLoad(entries, { compareKey: cmpNumber, compareVal: cmpString });
const asyncList = await SkipList.bulkLoadAsync(entries, { compareKey: cmpNumber, compareVal: cmpString }, { sliceEvery: 1 });
expect(asyncList.length).toBe(sync.length);
expect(asyncList.toArray()).toEqual(sync.toArray());
for (const probe of [0, 1, 42, 2499, 4999]) {
const e = entries[probe]!;
expect(asyncList.getRank(e.key, e.val)).toBe(sync.getRank(e.key, e.val));
}
expect(asyncList.getByRank(0)).toEqual(sync.getByRank(0));
expect(asyncList.getByRank(asyncList.length - 1)).toEqual(sync.getByRank(sync.length - 1));
expect(asyncList.range({ gte: 100, lte: 200, count: 7 })).toEqual(sync.range({ gte: 100, lte: 200, count: 7 }));
// Mutations after the sliced bulk load keep every invariant.
asyncList.insert(3, 'v-new');
asyncList.delete(100, 'v50');
sync.insert(3, 'v-new');
sync.delete(100, 'v50');
expect(asyncList.toArray()).toEqual(sync.toArray());
expect(asyncList.getRank(3, 'v-new')).toBe(sync.getRank(3, 'v-new'));
});
});
// ---- unit: gen-codec round-trips --------------------------------------------
describe('gen-codec', () => {
test('store image round-trips memory + disk refs, expiry and dt metadata', async () => {
const dir = await openTmp('codec-store');
// Keys are canonical (binary) strings: each char code is one byte of the
// key's utf8 encoding — the form the store and every index uses.
const kstr = (s: string): string => Buffer.from(s, 'utf8').toString('binary');
const records: StoreImageRecord[] = [
{ kstr: 'a', ref: { kind: 'memory', value: Buffer.from('v1') }, expireAt: 0, dt: null },
{ kstr: 'b', ref: { kind: 'disk', loc: { file: 'snapshot', off: 123, len: 45 } }, expireAt: 9999999999999, dt: { ts: 7 } },
{ kstr: 'c', ref: { kind: 'disk', loc: { file: 'wal', off: 9999999999, len: 1 } }, expireAt: 0, dt: null },
{ kstr: kstr('é-key-键'), ref: { kind: 'memory', value: Buffer.from('utf8 value ✓') }, expireAt: 0, dt: null },
];
const info = await writeStoreImage(path.join(dir, 'store'), records);
const buf = await fs.readFile(path.join(dir, 'store'));
expect(info.bytes).toBe(buf.length);
const parsed = parseGenerationBuffer(buf, 'MDGS', 4);
expect(parsed.bytes).toBe(info.bytes);
expect(parsed.crc32).toBe(info.crc32);
const out = [...readStoreImage(parsed.payload)];
expect(out.map((r) => r.kstr)).toEqual(records.map((r) => r.kstr));
expect(out[0]!.ref).toEqual(records[0]!.ref);
expect(out[1]!.ref).toEqual(records[1]!.ref);
expect(out[1]!.expireAt).toBe(records[1]!.expireAt);
expect(out[1]!.dt).toEqual({ ts: 7 });
expect(out[2]!.ref).toEqual(records[2]!.ref);
expect((out[3]!.ref as { value: Buffer }).value.toString('utf8')).toBe('utf8 value ✓');
expect(Buffer.from(out[3]!.kstr, 'binary').toString('utf8')).toBe('é-key-键');
});
test('a single-byte flip anywhere makes the crc check fail', async () => {
const dir = await openTmp('codec-corrupt');
await writeStoreImage(path.join(dir, 'store'), [
{ kstr: 'a', ref: { kind: 'memory', value: Buffer.from('v1') }, expireAt: 0, dt: null },
]);
const buf = await fs.readFile(path.join(dir, 'store'));
for (const pos of [0, 5, buf.length - 5]) {
const bad = Buffer.from(buf);
bad[pos] = bad[pos]! ^ 0xff;
expect(() => parseGenerationBuffer(bad, 'MDGS', 4)).toThrow(GenerationCorruptError);
}
// Wrong magic / unsupported version are structured errors too.
expect(() => parseGenerationBuffer(buf, 'XXXX', 1)).toThrow(GenerationCorruptError);
expect(() => parseGenerationBuffer(buf, 'MDGS', 99)).toThrow(GenerationCorruptError);
});
test('dt / secondary / compound / text-docs images round-trip', async () => {
const dir = await openTmp('codec-indexes');
await writeDtIndexImage(path.join(dir, 'dt'), [
{ name: 'ts', entries: [{ ms: 1, key: 'a' }, { ms: 2, key: 'b' }, { ms: 2, key: 'c' }] },
{ name: 'created', entries: [{ ms: 9, key: 'z' }] },
]);
const dt = readDtIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'dt')), 'MDGD', 1).payload);
expect(dt).toEqual([
{ name: 'ts', entries: [{ ms: 1, key: 'a' }, { ms: 2, key: 'b' }, { ms: 2, key: 'c' }] },
{ name: 'created', entries: [{ ms: 9, key: 'z' }] },
]);
await writeSecondaryIndexImage(path.join(dir, 'sec'), [
{
name: 'byKind',
field: 'kind',
type: 'equality',
unique: true,
sparse: false,
equality: [{ scalarKey: 'string:t1', pks: ['a', 'b'] }],
range: null,
},
{
name: 'byScore',
field: 'score',
type: 'range',
unique: false,
sparse: true,
equality: null,
range: [{ value: 1.5, pk: 'a' }, { value: 2, pk: 'b' }],
},
]);
const sec = readSecondaryIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'sec')), 'MDSI', 1).payload);
expect(sec[0]).toMatchObject({ name: 'byKind', unique: true, sparse: false, equality: [{ scalarKey: 'string:t1', pks: ['a', 'b'] }] });
expect(sec[1]).toMatchObject({ name: 'byScore', type: 'range', range: [{ value: 1.5, pk: 'a' }, { value: 2, pk: 'b' }] });
await writeCompoundIndexImage(path.join(dir, 'cmp'), [
{
name: 'g',
groupBy: 'kind',
orderBy: 'ts',
orderType: 'number',
groups: [
{ group: 't1', entries: [{ order: 5, pk: 'a' }, { order: 6, pk: 'b' }] },
{ group: 42, entries: [{ order: 1, pk: 'z' }] },
{ group: null, entries: [] },
{ group: true, entries: [{ order: 2, pk: 'y' }] },
],
},
{
name: 's',
groupBy: 'kind',
orderBy: 'name',
orderType: 'string',
groups: [{ group: 't1', entries: [{ order: 'abc', pk: 'a' }] }],
},
]);
const cmp = readCompoundIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'cmp')), 'MDCI', 1).payload);
expect(cmp[0]!.groups[0]).toEqual({ group: 't1', entries: [{ order: 5, pk: 'a' }, { order: 6, pk: 'b' }] });
expect(cmp[0]!.groups[1]).toEqual({ group: 42, entries: [{ order: 1, pk: 'z' }] });
expect(cmp[0]!.groups[2]).toEqual({ group: null, entries: [] });
expect(cmp[0]!.groups[3]).toEqual({ group: true, entries: [{ order: 2, pk: 'y' }] });
expect(cmp[1]!.orderType).toBe('string');
await writeTextDocsImage(path.join(dir, 'docs'), {
keys: ['a', undefined, 'c'],
docLens: [10, undefined, 3],
liveCount: 2,
removed: [1],
delta: [{ term: 'hello', docs: [{ docID: 2, freq: 4 }] }],
});
const docs = readTextDocsImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'docs')), 'MDTC', 1).payload);
expect(docs.keys).toEqual(['a', undefined, 'c']);
expect(docs.docLens).toEqual([10, undefined, 3]);
expect(docs.liveCount).toBe(2);
expect(docs.removed).toEqual([1]);
expect(docs.delta).toEqual([{ term: 'hello', docs: [{ docID: 2, freq: 4 }] }]);
});
});
// ---- unit: the sliced (event-loop-friendly) open-path variants ---------------
describe('sliced open-path variants', () => {
test('verifyFileIntegrityAsync streams a chunked verify with the sync error semantics', async () => {
const dir = await openTmp('verify-async');
const p = path.join(dir, 'raw.bin');
const payload = Buffer.alloc(2 * 1024 * 1024 + 123); // spans multiple 1 MiB chunks
for (let i = 0; i < payload.length; i++) payload[i] = (i * 31 + 7) & 0xff;
await fs.writeFile(p, payload);
const good = { bytes: payload.length, crc32: crc32(payload) >>> 0 };
// Same verdict as the sync verifier, on accept and on both rejections.
await verifyFileIntegrityAsync(p, good);
expect(() => verifyFileIntegritySync(p, good)).not.toThrow();
const sizeErr = await verifyFileIntegrityAsync(p, { bytes: good.bytes + 1, crc32: good.crc32 }).catch((e) => e);
expect(sizeErr).toBeInstanceOf(GenerationCorruptError);
expect((sizeErr as Error).message).toBe('file size does not match manifest record');
const crcErr = await verifyFileIntegrityAsync(p, { bytes: good.bytes, crc32: (good.crc32 ^ 1) >>> 0 }).catch((e) => e);
expect(crcErr).toBeInstanceOf(GenerationCorruptError);
expect((crcErr as Error).message).toBe('file crc does not match manifest record');
expect(() => verifyFileIntegritySync(p, { bytes: good.bytes, crc32: (good.crc32 ^ 1) >>> 0 })).toThrow(
'file crc does not match manifest record',
);
// An empty file is a valid edge: zero chunks, crc of nothing.
const empty = path.join(dir, 'empty.bin');
await fs.writeFile(empty, Buffer.alloc(0));
await verifyFileIntegrityAsync(empty, { bytes: 0, crc32: 0 });
});
test('async image parsers match the sync parsers entry-for-entry', async () => {
const dir = await openTmp('parse-async');
const secImage = [
{
name: 'byKind',
field: 'kind',
type: 'equality' as const,
unique: false,
sparse: true,
equality: [
{ scalarKey: 'string:t1', pks: ['a', 'b'] },
{ scalarKey: 'number:7', pks: ['c'] },
],
range: null,
},
{
name: 'byScore',
field: 'score',
type: 'range' as const,
unique: false,
sparse: true,
equality: null,
range: [
{ value: 1.5, pk: 'a' },
{ value: 2, pk: 'b' },
{ value: 2, pk: 'c' },
],
},
];
await writeSecondaryIndexImage(path.join(dir, 'sec'), secImage);
const secPayload = parseGenerationBuffer(await fs.readFile(path.join(dir, 'sec')), 'MDSI', 1).payload;
const secSync = readSecondaryIndexImage(secPayload);
const secAsync = await readSecondaryIndexImageAsync(parseGenerationBuffer(await fs.readFile(path.join(dir, 'sec')), 'MDSI', 1).payload, 1);
expect(secAsync).toEqual(secSync);
const cmpImage = [
{
name: 'g',
groupBy: 'kind',
orderBy: 'ts',
orderType: 'number' as const,
groups: [
{
group: 't1',
entries: [
{ order: 5, pk: 'a' },
{ order: 6, pk: 'b' },
],
},
{ group: null, entries: [] },
{ group: true, entries: [{ order: 2, pk: 'y' }] },
],
},
];
await writeCompoundIndexImage(path.join(dir, 'cmp'), cmpImage);
const cmpSync = readCompoundIndexImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'cmp')), 'MDCI', 1).payload);
const cmpAsync = await readCompoundIndexImageAsync(parseGenerationBuffer(await fs.readFile(path.join(dir, 'cmp')), 'MDCI', 1).payload, 1);
expect(cmpAsync).toEqual(cmpSync);
const docsImage = {
keys: ['a', undefined, 'c', 'd'] as (string | undefined)[],
docLens: [10, undefined, 3, 40] as (number | undefined)[],
liveCount: 3,
removed: [1],
delta: [
{ term: 'hello', docs: [{ docID: 2, freq: 4 }] },
{
term: 'world',
docs: [
{ docID: 0, freq: 1 },
{ docID: 3, freq: 2 },
],
},
],
};
await writeTextDocsImage(path.join(dir, 'docs'), docsImage);
const docsSync = readTextDocsImage(parseGenerationBuffer(await fs.readFile(path.join(dir, 'docs')), 'MDTC', 1).payload);
const docsAsync = await readTextDocsImageAsync(parseGenerationBuffer(await fs.readFile(path.join(dir, 'docs')), 'MDTC', 1).payload, 1);
expect(docsAsync).toEqual(docsSync);
expect(docsAsync).toEqual(docsImage);
});
test('Store.bulkLoadRefsAsync builds the same store as bulkLoadRefs', async () => {
const records: StoreImageRecord[] = [];
for (let i = 0; i < 300; i++) {
const kstr = `k${String(i).padStart(4, '0')}`; // already ascending
records.push({
kstr,
ref: { kind: 'memory', value: Buffer.from(`v${i}`) },
expireAt: i % 10 === 0 ? 9999999999999 : 0,
dt: i % 3 === 0 ? { ts: i } : null,
metaBytes: i % 3 === 0 ? Buffer.byteLength(JSON.stringify({ dt: { ts: i } }), 'utf8') : 0,
});
}
const syncStore = new Store({ activeExpireIntervalMs: 0 });
const asyncStore = new Store({ activeExpireIntervalMs: 0 });
try {
syncStore.bulkLoadRefs(records);
await asyncStore.bulkLoadRefsAsync(records, { sliceEvery: 1 });
expect([...asyncStore.rawKeys()]).toEqual([...syncStore.rawKeys()]);
expect(asyncStore.size).toBe(syncStore.size);
expect(asyncStore.bytes).toBe(syncStore.bytes);
expect(asyncStore.get('k0007')).toEqual(syncStore.get('k0007'));
expect(asyncStore.getRecord('k0150')).toMatchObject({ expireAt: 9999999999999, dt: { ts: 150 } });
} finally {
syncStore.close();
asyncStore.close();
}
});
test('loadImageAsync (secondary + compound) builds the same queryable state', async () => {
const docs = [];
for (let i = 0; i < 200; i++) docs.push({ pk: `k${i}`, doc: { kind: `t${i % 7}`, score: i % 100, ts: 1700000000000 + i } });
const secSrc = new IndexManager();
secSrc.create('byKind', { field: 'kind' });
secSrc.create('byScore', { field: 'score', type: 'range' });
const cmpSrc = new CompoundIndexManager();
cmpSrc.create('byKindTime', { groupBy: 'kind', orderBy: 'ts' });
for (const { pk, doc } of docs) {
secSrc.add(pk, doc);
cmpSrc.add(pk, doc, null);
}
const secDst = new IndexManager();
secDst.create('byKind', { field: 'kind' });
secDst.create('byScore', { field: 'score', type: 'range' });
for (const image of secSrc.exportImage()) await secDst.loadImageAsync(image, { sliceEvery: 1 });
expect(secDst.findEq('byKind', 't3').sort()).toEqual(secSrc.findEq('byKind', 't3').sort());
expect(secDst.findRange('byScore', { min: 10, max: 42 })).toEqual(secSrc.findRange('byScore', { min: 10, max: 42 }));
const cmpDst = new CompoundIndexManager();
cmpDst.create('byKindTime', { groupBy: 'kind', orderBy: 'ts' });
for (const image of cmpSrc.exportImage().images) await cmpDst.loadImageAsync(image, { sliceEvery: 1 });
expect(cmpDst.range('byKindTime', 't2', { limit: 5 })).toEqual(cmpSrc.range('byKindTime', 't2', { limit: 5 }));
expect(cmpDst.range('byKindTime', 't5', { limit: 50 })).toEqual(cmpSrc.range('byKindTime', 't5', { limit: 50 }));
});
test('TextIndex.attachImageAsync attaches the same base as attachImage', async () => {
const dir = await openTmp('attach-async');
const postingsPath = path.join(dir, 'ft.postings');
const src = new TextIndex({ name: 'ft', fields: ['text'], postingsPath });
const syncTi = new TextIndex({ name: 'ft', fields: ['text'] });
const asyncTi = new TextIndex({ name: 'ft', fields: ['text'] });
try {
const corpus = [];
for (let i = 0; i < 300; i++) corpus.push({ key: `k${i}`, value: { text: `hello world ${i} 持久化 ${i % 7}` } });
await src.build(corpus);
src.add('late1', { text: 'late write walrus' }); // lands in the delta
src.remove('k5'); // tombstone
const image = src.exportImageState();
syncTi.attachImage({ postingsPath, ...image });
const docsImage = {
keys: image.keys,
docLens: (() => {
const out: (number | undefined)[] = [];
for (let i = 0; i < image.keys.length; i++) out.push(image.docLens.get(i));
return out;
})(),
liveCount: image.liveCount,
removed: [...image.removed],
delta: [...image.delta].map(([term, m]) => ({ term, docs: [...m].map(([docID, freq]) => ({ docID, freq })) })),
};
const dictEntries = [...image.dict].map(([term, e]) => ({ term, off: e.off, len: e.len, df: e.df }));
await asyncTi.attachImageAsync({ postingsPath, dictEntries, docs: docsImage }, { sliceEvery: 1 });
expect(asyncTi.exportImageState()).toEqual(syncTi.exportImageState());
expect(asyncTi.N).toBe(syncTi.N);
expect(asyncTi.search('walrus').length).toBe(syncTi.search('walrus').length);
expect(asyncTi.search('持久化').length).toBeGreaterThan(0);
} finally {
src.close();
syncTi.close();
asyncTi.close();
}
});
});
// ---- integration: build / publish / load ------------------------------------
describe('generation build + load', () => {
test('explicit build publishes; reopen loads the generation with zero rebuilds', async () => {
const dir = await openTmp('basic');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 2000);
await db.rebuildGeneration();
const gen = db.getIndexGeneration();
expect(gen).not.toBeNull();
expect(db.stats.generationBuilds).toBeGreaterThanOrEqual(1);
expect(db.stats.generationBuildErrors).toBe(0);
expect(fsSync.existsSync(path.join(dir, 'CURRENT'))).toBe(true);
expect(fsSync.existsSync(path.join(dir, 'generations', gen!.id, 'manifest.json'))).toBe(true);
// WAL delta after the checkpoint.
for (let i = 2000; i < 2100; i++) {
await db.set(`k${i}`, { kind: 't1', score: 1, ts: 1700000000000 + i, text: `delta ${i} 增量` }, { dt: { ts: 1700000000000 + i } });
}
await db.del('k0');
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
expect(db.stats.generationIndexRebuilds).toBe(0);
expect(db.stats.indexRebuildDecoded).toBe(0); // proves: no corpus re-decode
expect(db.stats.textRebuildDurationMs).toBe(0); // proves: no tokenization
expect(db.getIndexGeneration()?.id).toBe(gen!.id);
expect(db.recoveryInfo?.indexGeneration?.id).toBe(gen!.id);
assertSeededDb(db, 2000, 2099, 110, false);
// The WAL delta landed too.
expect(db.get('k0')).toBeUndefined();
expect(db.get('k2099')).toMatchObject({ kind: 't1' });
expect(db.search('ft', '增量').length).toBe(50);
await db.close();
});
test('disk valueMode round-trips through a generation', async () => {
const dir = await openTmp('disk-mode');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', valueMode: 'disk' });
await seedIndexedDb(db, 500);
await db.rebuildGeneration();
for (let i = 500; i < 550; i++) {
await db.set(`k${i}`, { kind: 't1', score: 2, ts: 1700000000000 + i, text: `delta disk ${i}` }, { dt: { ts: 1700000000000 + i } });
}
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', valueMode: 'disk' });
expect(db.stats.generationLoads).toBe(1);
assertSeededDb(db, 500, 550, 60);
expect(db.size).toBe(550);
expect(db.get('k42')).toMatchObject({ score: 42 });
expect(db.get('k549')).toMatchObject({ kind: 't1' });
expect(db.search('ft', 'hello').length).toBe(50);
await db.close();
});
test('legacy database (generations disabled) gets a background first generation on open', async () => {
const dir = await openTmp('legacy-first');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', indexGenerations: false });
await seedIndexedDb(db, 800);
await db.close();
expect(fsSync.existsSync(path.join(dir, 'CURRENT'))).toBe(false);
// First open with generations enabled: legacy recovery serves it, then a
// background build publishes the first generation.
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(0);
assertSeededDb(db, 800);
await waitFor(() => fsSync.existsSync(path.join(dir, 'CURRENT')), 'background first generation');
await waitFor(() => db.stats.generationBuilds >= 1, 'generationBuilds stat');
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
expect(db.stats.indexRebuildDecoded).toBe(0);
assertSeededDb(db, 800);
await db.close();
});
test('compaction publishes the generation transactionally (no sync postings tail)', async () => {
const dir = await openTmp('compact-publish');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', compactThresholdBytes: 1 << 30 });
await seedIndexedDb(db, 1000);
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
const before = db.getIndexGeneration();
await db.compact();
const after = db.getIndexGeneration();
expect(after).not.toBeNull();
expect(after!.id).not.toBe(before?.id);
// The live text base moved into the generation: legacy root postings files
// are reclaimed.
expect(fsSync.readdirSync(dir).filter((f) => /^db\.text-.*\.postings$/.test(f))).toEqual([]);
assertSeededDb(db, 1000);
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
expect(db.getIndexGeneration()?.id).toBe(after!.id);
assertSeededDb(db, 1000);
await db.close();
});
test('retention keeps the current and previous generation only', async () => {
const dir = await openTmp('retention');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 100);
await db.rebuildGeneration();
await db.rebuildGeneration();
await db.rebuildGeneration();
const current = db.getIndexGeneration()!.id;
await waitFor(() => {
try {
return fsSync.readdirSync(path.join(dir, 'generations')).filter((g) => !g.includes('.tmp-')).length <= 2;
} catch {
return false;
}
}, 'retention sweep');
const left = (await listGenerations(dir)).filter((g) => !g.tmp).map((g) => g.id);
expect(left).toContain(current);
expect(left.length).toBeLessThanOrEqual(2);
await db.close();
});
test('TTL records sealed into a generation expire correctly at load (indexes reconciled)', async () => {
const dir = await openTmp('ttl');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await db.createIndex('byKind', { field: 'kind' });
await db.createTextIndex('ft', { fields: ['text'] });
await db.set('stay', { kind: 'a', text: 'permanent hello' });
await db.set('gone', { kind: 'a', text: 'ephemeral hello' }, { ttl: 50 });
await db.rebuildGeneration();
await new Promise((r) => setTimeout(r, 120));
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
expect(db.get('gone')).toBeUndefined();
expect(db.get('stay')).toBeDefined();
expect(db.findEq('byKind', 'a').map((r) => r.key)).toEqual(['stay']);
expect(db.search('ft', 'hello').map((h) => h.key)).toEqual(['stay']);
await db.close();
});
test('a definition change rebuilds only the affected index', async () => {
const dir = await openTmp('def-change');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 1000);
await db.rebuildGeneration();
await db.dropIndex('byScore');
await db.createIndex('byScore', { field: 'score', type: 'range' }); // same shape, but drop+create rewrote the sidecar
await db.createIndex('byNew', { field: 'kind' });
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
// byNew has no image (created after the build) — exactly one index rebuilt.
expect(db.stats.generationIndexRebuilds).toBe(1);
expect(db.findEq('byNew', 't3').length).toBe([...Array(1000)].filter((_, i) => i % 7 === 3).length);
expect(db.findRange('byScore', { min: 42, max: 42 }).length).toBe(10);
// Text + dt + compound came from the generation (no corpus re-decode for them).
expect(db.search('ft', '持久化').length).toBeGreaterThan(0);
await db.close();
});
test('clean text index: background build then a second build re-links (no ENOENT)', async () => {
// Regression: the clean fast path hard-links the live base into the new
// generation, and the reclaim step deletes the root file — the publish
// must repoint the live handle into the CURRENT generation or the next
// build's link source is gone.
const dir = await openTmp('clean-relink');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', indexGenerations: false });
await db.createTextIndex('ft', { fields: ['text'] });
for (let i = 0; i < 200; i++) await db.set(`k${i}`, { text: `hello world doc ${i}` });
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await waitFor(() => db.stats.generationBuilds >= 1, 'background first generation');
expect(db.search('ft', 'hello', { limit: 1000 }).length).toBe(200);
await db.rebuildGeneration(); // must not throw ENOENT
expect(db.stats.generationBuildErrors).toBe(0);
expect(db.search('ft', 'hello', { limit: 1000 }).length).toBe(200);
const gen = db.getIndexGeneration()!.id;
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.getIndexGeneration()?.id).toBe(gen);
expect(db.search('ft', 'hello', { limit: 1000 }).length).toBe(200);
await db.close();
});
test('clean text index loaded from a generation survives consecutive builds', async () => {
const dir = await openTmp('clean-relink-2');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await db.createTextIndex('ft', { fields: ['text'] });
for (let i = 0; i < 100; i++) await db.set(`k${i}`, { text: `hello doc ${i}` });
await db.rebuildGeneration(); // g1 (dirty -> staged)
await db.close();
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
for (let round = 0; round < 3; round++) {
await db.rebuildGeneration(); // clean re-publish each time
// Let the async retention cleanup settle between rounds.
await new Promise((r) => setTimeout(r, 150));
}
expect(db.stats.generationBuildErrors).toBe(0);
expect(db.search('ft', 'hello').length).toBe(50);
await db.close();
});
test('read-only reader keeps serving through a writer generation switch', async () => {
const dir = await openTmp('reader-switch');
let writer = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(writer, 500);
await writer.rebuildGeneration();
const reader = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', readOnly: true });
expect(reader.stats.generationLoads).toBe(1);
assertSeededDb(reader, 500);
// The writer publishes a new generation while the reader holds the old one.
for (let i = 500; i < 600; i++) {
await writer.set(`k${i}`, { kind: 't2', score: 3, ts: 1700000000000 + i, text: `second wave ${i}` }, { dt: { ts: 1700000000000 + i } });
}
await writer.rebuildGeneration();
// The reader's open handles keep its generation servable: queries keep
// answering the (consistent) old view without any full-rebuild stall.
assertSeededDb(reader, 500);
expect(reader.get('k250')).toMatchObject({ score: 50 });
await reader.close();
const reader2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', readOnly: true });
expect(reader2.stats.generationLoads).toBe(1);
expect(reader2.size).toBe(600);
expect(reader2.get('k550')).toMatchObject({ kind: 't2' });
await closeAll(reader2, writer);
});
});
// ---- fault matrix ------------------------------------------------------------
describe('generation fault matrix', () => {
test('unknown manifest format version falls back without deleting anything', async () => {
const dir = await openTmp('unknown-version');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 300);
await db.rebuildGeneration();
await db.close();
// Simulate a newer binary's generations: bump the format version of EVERY
// published manifest, so no candidate can load.
const manifestPaths: string[] = [];
for (const g of await listGenerations(dir)) {
if (g.tmp) continue;
const p = path.join(dir, 'generations', g.id, 'manifest.json');
const manifest = JSON.parse(await fs.readFile(p, 'utf8')) as { format: number };
manifest.format = 99;
await fs.writeFile(p, JSON.stringify(manifest), 'utf8');
manifestPaths.push(p);
}
expect(manifestPaths.length).toBeGreaterThanOrEqual(1);
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(0);
expect(db.stats.generationLoadFallbacks).toBeGreaterThanOrEqual(1);
expect(db.stats.lastGenerationFallback).toContain('unknown format version');
assertSeededDb(db, 300); // legacy full recovery served it
// The foreign generations are still on disk, untouched.
for (const p of manifestPaths) {
expect(fsSync.existsSync(p)).toBe(true);
expect(JSON.parse(await fs.readFile(p, 'utf8')).format).toBe(99);
}
await db.close();
});
test('corrupt store image discards the generation, never the snapshot/WAL', async () => {
const dir = await openTmp('corrupt-store');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 300);
await db.rebuildGeneration();
await db.close();
// Corrupt the store image of EVERY candidate generation, so the load path
// has nowhere to go but the legacy full recovery.
for (const g of await listGenerations(dir)) {
if (g.tmp) continue;
const p = path.join(dir, 'generations', g.id, 'store');
const buf = await fs.readFile(p);
buf[buf.length - 5] = buf[buf.length - 5]! ^ 0xff; // last payload byte before the crc
await fs.writeFile(p, buf);
}
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(0);
expect(db.stats.generationLoadFallbacks).toBeGreaterThanOrEqual(1);
assertSeededDb(db, 300);
// Authoritative data untouched — and even the corrupt generation is only
// abandoned, never deleted by the load path.
expect(fsSync.existsSync(path.join(dir, 'db.wal'))).toBe(true);
for (const g of await listGenerations(dir)) {
if (!g.tmp) expect(fsSync.existsSync(path.join(dir, 'generations', g.id, 'store'))).toBe(true);
}
await db.close();
});
test.each(['dt.index', 'secondary.index', 'compound.index', 'text-ft.dictionary', 'text-ft.docs', 'text-ft.postings'])(
'corrupt %s rebuilds only that index at load',
async (file) => {
const dir = await openTmp('corrupt-one');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 400);
await db.rebuildGeneration();
const genId = db.getIndexGeneration()!.id;
await db.close();
const p = path.join(dir, 'generations', genId, file);
const buf = await fs.readFile(p);
buf[Math.floor(buf.length / 2)] = buf[Math.floor(buf.length / 2)]! ^ 0xff;
await fs.writeFile(p, buf);
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
expect(db.stats.generationIndexRebuilds).toBeGreaterThanOrEqual(1);
assertSeededDb(db, 400);
await db.close();
},
);
test('interrupted store-image write: no publish, CURRENT keeps the previous generation, next build succeeds', async () => {
const dir = await openTmp('interrupt-write');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 300);
await db.rebuildGeneration();
const first = db.getIndexGeneration()!.id;
const restore = failFileWrites((p) => p.includes('.tmp-') && p.endsWith('store'), 'writev', new Error('injected write failure'));
await expect(db.rebuildGeneration()).rejects.toThrow('injected write failure');
restore();
expect(db.stats.generationBuildErrors).toBeGreaterThanOrEqual(1);
// CURRENT never moved off the last complete generation.
expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first);
// The db itself is fully healthy.
await db.set('after', { kind: 't1', score: 1, ts: 1, text: 'post failure' });
expect(db.get('after')).toBeDefined();
await db.rebuildGeneration();
expect(db.getIndexGeneration()!.id).not.toBe(first);
await db.close();
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db2.stats.generationLoads).toBe(1);
expect((await listGenerations(dir)).filter((g) => g.tmp)).toEqual([]);
assertSeededDb(db2, 300, 301);
expect(db2.get('after')).toBeDefined();
await db2.close();
});
test.each(['store', 'dt.index', 'secondary.index', 'compound.index', 'text-ft.dictionary', 'text-ft.docs'])(
'interrupted %s write keeps the previous generation and all data',
async (file) => {
const dir = await openTmp('interrupt-each');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 200);
await db.rebuildGeneration(); // g-1 published
const first = db.getIndexGeneration()!.id;
for (let i = 200; i < 260; i++) {
await db.set(`k${i}`, { kind: 't2', score: 5, ts: 1700000000000 + i, text: `more ${i}` }, { dt: { ts: 1700000000000 + i } });
}
const restore = failFileWrites((p) => p.includes('.tmp-') && p.endsWith(file), 'writev', new Error(`injected ${file} failure`));
await expect(db.rebuildGeneration()).rejects.toThrow(`injected ${file} failure`);
restore();
// CURRENT still points at g-1; data is complete either way.
expect(db.getIndexGeneration()!.id).toBe(first);
await db.close();
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db2.getIndexGeneration()?.id).toBe(first);
expect(db2.size).toBe(260);
expect(db2.get('k255')).toMatchObject({ kind: 't2' });
await db2.close();
},
);
test('file fsync failure aborts the build without touching CURRENT', async () => {
const dir = await openTmp('fsync-fail');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 200);
await db.rebuildGeneration();
const first = db.getIndexGeneration()!.id;
const restore = failFileWrites((p) => p.includes('.tmp-') && p.endsWith('store'), 'sync', new Error('injected fsync failure'));
await expect(db.rebuildGeneration()).rejects.toThrow('injected fsync failure');
restore();
expect(db.stats.generationBuildErrors).toBeGreaterThanOrEqual(1);
expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first);
await db.rebuildGeneration();
expect(db.getIndexGeneration()!.id).not.toBe(first);
await db.close();
});
test('crash before the generation dir rename: CURRENT unchanged, tmp swept at next open', async () => {
const dir = await openTmp('crash-pre-rename');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 200);
await db.rebuildGeneration();
const first = db.getIndexGeneration()!.id;
const restore = failRenames((src) => src.includes('.tmp-'), new Error('injected crash before rename'));
await expect(db.rebuildGeneration()).rejects.toThrow('injected crash before rename');
restore();
expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first);
await db.close();
// Next open sweeps the stranded tmp dir and the background build recovers.
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db2.getIndexGeneration()?.id).toBe(first);
assertSeededDb(db2, 200);
await db2.close();
expect((await listGenerations(dir)).filter((g) => g.tmp)).toEqual([]);
});
test('crash after the dir rename but before CURRENT: old CURRENT wins, stray dir cleaned by the next publish', async () => {
const dir = await openTmp('crash-pre-current');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 200);
await db.rebuildGeneration();
const first = db.getIndexGeneration()!.id;
const before = (await listGenerations(dir)).filter((g) => !g.tmp).map((g) => g.id);
const restore = failRenames((src, dst) => src.includes('CURRENT.tmp-') || dst.endsWith('CURRENT'), new Error('injected crash before CURRENT'));
await expect(db.rebuildGeneration()).rejects.toThrow('injected crash before CURRENT');
restore();
// CURRENT still names the previous generation; a complete-but-unreferenced
// generation dir lingers next to it.
expect((await fs.readFile(path.join(dir, 'CURRENT'), 'utf8')).trim()).toBe(first);
const gens = (await listGenerations(dir)).filter((g) => !g.tmp).map((g) => g.id);
expect(gens.length).toBe(before.length + 1);
const stray = gens.find((g) => !before.includes(g))!;
expect(stray).toBeDefined();
expect((await readManifest(dir, stray)).id).toBe(stray);
await db.close();
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db2.getIndexGeneration()?.id).toBe(first);
assertSeededDb(db2, 200);
await db2.rebuildGeneration();
await waitFor(() => {
try {
return fsSync.readdirSync(path.join(dir, 'generations')).filter((g) => !g.includes('.tmp-')).length <= 2;
} catch {
return false;
}
}, 'stray dir cleanup');
await db2.close();
});
test('crash after CURRENT replacement: the new generation loads', async () => {
const dir = await openTmp('crash-post-current');
const writer = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(writer, 200);
await writer.rebuildGeneration();
const id = writer.getIndexGeneration()!.id;
// The writer stays OPEN (its lock held) — as after a crash, the files on
// disk must already tell the whole story: a read-only peer loads the
// freshly published generation.
const peer = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', onLockFail: 'readonly' });
expect(peer.readOnly).toBe(true);
expect(peer.getIndexGeneration()?.id).toBe(id);
expect(peer.stats.generationLoads).toBe(1);
assertSeededDb(peer, 200);
await closeAll(peer, writer);
});
test('old-generation cleanup failure after publish is harmless', async () => {
const dir = await openTmp('cleanup-fail');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 200);
await db.rebuildGeneration();
const first = db.getIndexGeneration()!.id;
const original = fs.rm;
(fs as unknown as Record<string, unknown>).rm = (p: string, opts?: unknown) =>
String(p).includes('generations') && String(p).endsWith(first)
? Promise.reject(new Error('injected cleanup failure'))
: original(p, opts as Parameters<typeof fs.rm>[1]);
cleanups.push(() => {
(fs as unknown as Record<string, unknown>).rm = original;
});
await db.rebuildGeneration();
const second = db.getIndexGeneration()!.id;
expect(second).not.toBe(first);
await db.close();
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db2.getIndexGeneration()?.id).toBe(second);
assertSeededDb(db2, 200);
await db2.close();
});
test('WAL rollback during a build aborts it (expected churn, not an error)', async () => {
const dir = await openTmp('rollback-abort');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 6000); // big enough that the walk spans several ticks
const build = db.rebuildGeneration();
// Wait until the build has actually registered its mutation queue (the
// walk is underway), then drive a WAL write failure: the group rollback
// calls restoreGroupKey, which must abort the in-flight build.
await waitFor(() => (db as unknown as { genBuild: unknown }).genBuild !== null, 'generation build registered');
const origAppend = db.wal.appendLoc.bind(db.wal);
(db.wal as unknown as { appendLoc: unknown }).appendLoc = () => ({
offset: -1,
batchId: -1,
done: Promise.reject(Object.assign(new Error('injected WAL failure'), { code: 'WAL_POISONED' })),
});
await expect(db.set('boom', { kind: 't1' })).rejects.toThrow();
(db.wal as unknown as { appendLoc: unknown }).appendLoc = origAppend;
await build; // aborted builds resolve cleanly
expect(db.stats.generationBuildAborts).toBeGreaterThanOrEqual(1);
expect(db.stats.generationBuildErrors).toBe(0);
// The db is consistent and a fresh build succeeds.
await db.rebuildGeneration();
expect(db.getIndexGeneration()).not.toBeNull();
assertSeededDb(db, 6000);
await db.close();
});
test('backup/restore carries the generation tree and safely falls back on inode change', async () => {
const dir = await openTmp('backup-src');
const destDir = path.join(await openTmp('backup-dst'), 'b');
const backupDir = path.join(await openTmp('backup-dir'), 'bak');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 300);
await db.rebuildGeneration();
await db.backup(backupDir, { compact: false });
// The backup includes CURRENT and the generations tree.
expect(fsSync.existsSync(path.join(backupDir, 'CURRENT'))).toBe(true);
expect(fsSync.existsSync(path.join(backupDir, 'generations'))).toBe(true);
await db.close();
// Restore: the copied files sit on NEW inodes, so the generation's WAL
// anchor cannot validate — the open must fall back (legacy full recovery
// or a safe re-checkpoint), never serve a mismatched image, never lose
// data.
const restored = await MiniDb.restore<Record<string, unknown>>(backupDir, destDir, { valueCodec: 'json' });
assertSeededDb(restored, 300);
await restored.close();
// A reopened writer re-checkpoints in the background.
const db2 = await MiniDb.open<Record<string, unknown>>({ dir: destDir, valueCodec: 'json' });
assertSeededDb(db2, 300);
await waitFor(() => db2.getIndexGeneration() !== null || db2.stats.generationBuilds >= 1, 'post-restore re-checkpoint');
await db2.close();
});
test('writer builds while a read-only reader queries (no interference)', async () => {
const dir = await openTmp('reader-during-build');
const writer = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(writer, 1000);
const reader = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', readOnly: true });
assertSeededDb(reader, 1000);
const build = writer.rebuildGeneration();
// The reader keeps answering (its view predates the build) while the build
// walks + publishes.
expect(reader.get('k500')).toMatchObject({ score: 0 });
expect(reader.search('ft', 'hello').length).toBe(50);
await build;
expect(writer.getIndexGeneration()).not.toBeNull();
assertSeededDb(reader, 1000);
await closeAll(reader, writer);
});
test('seal flush: a WAL write failing after being imaged aborts the build instead of publishing a resurrectable write', async () => {
const dir = await openTmp('seal-flush');
const { WAL } = await import('../src/wal.js');
// No text indexes: nothing needs a worker build — the case that used to
// skip the seal's WAL flush entirely. fsyncPolicy 'no' means no
// background sync timer, so the write's batch is exactly the next writev.
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json', fsyncPolicy: 'no' });
await db.set('stable', { kind: 't0', score: 0, ts: 1, text: 'stable doc' });
await db.rebuildGeneration();
const first = db.getIndexGeneration()!.id;
// Park the write's WAL batch in flight: the op is already applied (so the
// build images it) while its frame has not landed yet.
type Fh = { writev: (bufs: readonly Uint8Array[]) => Promise<{ bytesWritten: number }> };
const wal = (db as unknown as { wal: InstanceType<typeof WAL> }).wal;
const fh = (wal as unknown as { fh: Fh | null }).fh!;
const origWritev = fh.writev.bind(fh);
let fired = false;
const entered = deferred<void>();
const proceed = deferred<void>();
(fh as { writev: unknown }).writev = async (bufs: readonly Uint8Array[]) => {
if (fired) return origWritev(bufs);
fired = true;
entered.resolve();
await proceed.promise;
throw new Error('injected wal writev failure');
};
cleanups.push(() => {
(fh as { writev: unknown }).writev = origWritev;
});
const flushCalls = vi.spyOn(WAL.prototype, 'flush');
cleanups.push(() => flushCalls.mockRestore());
const ghostSet = db.set('ghost', { kind: 't1', score: 1, ts: 2, text: 'ghost doc' });
await entered.promise; // the batch is in flight; the commit is still pending
const build = db.rebuildGeneration();
// The seal's flush chains onto the in-flight batch — the build is now
// stuck at the seal (pre-fix it flushed only for worker text builds and
// would publish without settling the frame below the checkpoint).
await waitFor(() => flushCalls.mock.calls.length > 0, 'seal WAL flush');
proceed.resolve();
// The batch's failure poisons the WAL; the seal flush observes the poison
// and the build aborts instead of publishing the imaged write.
await expect(build).rejects.toThrow('WAL is poisoned');
await expect(ghostSet).rejects.toThrow('injected wal writev failure');
expect(db.get('ghost')).toBeUndefined(); // rolled back from the live store
expect(db.getIndexGeneration()!.id).toBe(first); // CURRENT never moved
await db.close().catch(() => {});
// A reopen cannot resurrect the rejected write from the generation.
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db2.get('ghost')).toBeUndefined();
expect(db2.get('stable')).toMatchObject({ kind: 't0' });
await db2.close();
});
});
// ---- stage 6: shutdown drain / cancel ------------------------------------------
describe('stage 6: maintenance shutdown semantics', () => {
test('close() waits for the publish critical section instead of cancelling it', async () => {
const dir = await openTmp('shutdown-publish');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 5000); // big enough to take the worker path
// Park the FIRST rename of the publish sequence (tmp dir -> g-N): at
// that point the build is inside its publishing critical section.
const { barrier } = await import('./helpers.js');
const gate = barrier(fs, 'rename', 1);
const buildP = db.rebuildGeneration().catch((e) => e);
await gate.entered; // provably inside publishGeneration's first rename
let closed = false;
const closeP = db.close().then(() => {
closed = true;
});
// The shutdown must NOT cancel the publishing task: close stays pending
// while the rename is parked.
for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r));
expect(closed).toBe(false);
gate.restore();
gate.release();
await buildP;
await closeP;
expect(closed).toBe(true);
// The publish completed fully: CURRENT names the new generation.
expect(fsSync.existsSync(path.join(dir, 'CURRENT'))).toBe(true);
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db2.stats.generationLoads).toBe(1);
assertSeededDb(db2, 5000);
await db2.close();
}, 60000);
test('close() safely cancels a queued maintenance task (backpressure + drain)', async () => {
const dir = await openTmp('shutdown-queued');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 5000);
// Occupy the scheduler with a running build, queue another behind it,
// then close: the queued one is cancelled, the close completes, the data
// is intact.
const first = db.rebuildGeneration().catch(() => {});
const second = db.rebuildGeneration().then(
() => 'completed',
(e) => e,
);
await db.close();
await first;
const outcome = await second;
// The second build was either cancelled (queue drain) or completed
// before the close — never left hanging, and never corrupts anything.
expect(['completed'].includes(outcome as string) || outcome instanceof Error).toBe(true);
const db2 = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
// The cancelled builds left no generation behind: this reopen took the
// no-generation fallback path and may have DEFERRED the text base build —
// wait out the building window (BOTH indexes, the task builds them
// sequentially) before asserting search results.
if (db2.textIndexBuilding('ft') || db2.textIndexBuilding('tri')) {
await waitFor(() => !db2.textIndexBuilding('ft') && !db2.textIndexBuilding('tri'), 'deferred text build');
}
assertSeededDb(db2, 5000);
await db2.close();
}, 60000);
});
describe('generation availability triggers (wal-growth + close publish)', () => {
/** Write ~5 MiB of frames (1 KiB values) without any text index — enough
* to cross the wal-growth staleness threshold while staying far under the
* 64 MiB compaction threshold. */
async function growWalPast(db: MiniDb<Record<string, unknown>>, n = 5200): Promise<void> {
const pad = 'x'.repeat(1024);
const BATCH = 200;
for (let base = 0; base < n; base += BATCH) {
await db.batch(
Array.from({ length: Math.min(BATCH, n - base) }, (_, i) => ({
op: 'set' as const,
key: `k${base + i}`,
value: { text: `${pad}${base + i}` },
})),
);
}
}
test('wal-growth trigger publishes a generation without any compaction', async () => {
const dir = await openTmp('gen-wal-growth');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
db.genBuildKickMinIntervalMs = 0; // test knob: no throttle
// The db started EMPTY, so the open-time generation kick did not fire;
// nothing re-triggers until the WAL crosses the staleness threshold.
expect(db.getIndexGeneration()).toBeNull();
await growWalPast(db);
await waitFor(() => db.getIndexGeneration() !== null, 'wal-growth generation build');
expect(db.stats.compactions).toBe(0); // 5 MiB ≪ 64 MiB threshold
expect(db.stats.generationBuilds).toBeGreaterThanOrEqual(1);
await db.close();
}, 60000);
test('close() publishes a missing/stale generation best-effort', async () => {
const dir = await openTmp('gen-close-publish');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
db.genBuildKickMinIntervalMs = 3_600_000; // suppress the runtime trigger
await growWalPast(db);
expect(db.getIndexGeneration()).toBeNull();
await db.close();
// The close-time publish left a valid generation behind: the next open
// takes the attach path instead of the full-recovery fallback.
expect(fsSync.existsSync(path.join(dir, 'CURRENT'))).toBe(true);
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(1);
expect(db.size).toBe(5200);
await db.close();
}, 60000);
test('a failed build backs the wal-growth trigger off instead of re-kicking every write', async () => {
const dir = await openTmp('gen-kick-backoff');
const db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
db.genBuildKickMinIntervalMs = 0;
db.genBuildKickFailureBackoffMs = 60_000;
// Make every publish rename fail: the kicked build dies at the publish
// boundary and is accounted as a build failure.
const original = fs.rename;
(fs as unknown as Record<string, unknown>).rename = (src: string, dst: string) =>
String(src).includes('.tmp-') ? Promise.reject(new Error('injected publish failure')) : original(src, dst);
cleanups.push(() => {
(fs as unknown as Record<string, unknown>).rename = original;
});
await growWalPast(db);
await waitFor(() => db.stats.generationBuildErrors >= 1, 'first kicked build to fail');
(fs as unknown as Record<string, unknown>).rename = original;
expect(db.getIndexGeneration()).toBeNull();
// Inside the backoff window further writes must NOT re-kick a build.
await db.set('after-failure', { text: 'y'.repeat(1024) });
for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r));
const active = db.maintenanceStatus().filter((t) => t.kind === 'generation-build' && (t.state === 'running' || t.state === 'queued'));
expect(active).toEqual([]);
// With the backoff lifted the very next write kicks a successful build.
db.genBuildKickFailureBackoffMs = 0;
await db.set('after-backoff', { text: 'z'.repeat(1024) });
await waitFor(() => db.getIndexGeneration() !== null, 'post-backoff generation build');
await db.close();
}, 60000);
});
describe('open lifecycle status (phase timings + state machine)', () => {
test('a healthy generation open reports generation-load with zero corpus work', async () => {
const dir = await openTmp('lifecycle-healthy');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 2000);
await db.rebuildGeneration();
const gen = db.getIndexGeneration()!;
await db.close();
// Hard proof that no full-corpus tokenization runs on the attach path:
// the staged builder (the only in-open corpus tokenizer besides the
// bounded rebuild) must not be entered at all.
const buildSpy = vi.spyOn(TextIndex.prototype, 'build');
try {
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(buildSpy).not.toHaveBeenCalled();
} finally {
buildSpy.mockRestore();
}
expect(db.stats.generationLoads).toBe(1);
expect(db.stats.generationIndexRebuilds).toBe(0);
expect(db.stats.indexRebuildDecoded).toBe(0); // no value re-decode
expect(db.stats.textRebuildDurationMs).toBe(0); // no tokenization
const status = db.lifecycleStatus();
expect(status.state).toBe('ready');
expect(status.path).toEqual(['no-generation', 'generation-load', 'wal-catch-up', 'ready']);
expect(status.openedAt).not.toBeNull();
expect(status.phases.openMs).toBeGreaterThan(0);
expect(status.phases.generationCandidateLoadMs).toBeGreaterThan(0);
expect(status.phases.storeImageLoadMs).toBeGreaterThan(0);
expect(status.phases.postingsIntegrityCheckMs).toBeGreaterThan(0);
expect(status.phases.textImageLoadMs).toBeGreaterThan(0);
expect(status.phases.walScanMs).toBeGreaterThanOrEqual(0);
expect(status.phases.fullRecoveryMs).toBe(0);
expect(status.phases.textRebuildMs).toBe(0);
expect(status.textIndexes).toEqual({ ft: 'image', tri: 'image' });
expect(status.pendingTextIndexes).toEqual([]);
expect(db.recoveryInfo?.indexGeneration?.id).toBe(gen.id);
expect(db.recoveryInfo?.walDeltaAppliedOps).toBe(0);
assertSeededDb(db, 2000);
await db.close();
});
test('a corrupt generation falls back to full-rebuild (only then is the corpus re-tokenized)', async () => {
const dir = await openTmp('lifecycle-corrupt');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 2000);
await db.rebuildGeneration();
const gen = db.getIndexGeneration()!;
await db.close();
// Flip one byte inside the generation's store image: the manifest crc
// rejects the candidate and the open must fall back to the legacy full
// recovery — the ONLY path allowed to re-tokenize the corpus.
const storeImage = path.join(dir, 'generations', gen.id, 'store');
const raw = await fs.readFile(storeImage);
raw[Math.floor(raw.length / 2)]! ^= 0xff;
await fs.writeFile(storeImage, raw);
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(0);
expect(db.stats.generationLoadFallbacks).toBe(1);
expect(db.stats.lastGenerationFallback).toContain(gen.id);
expect(db.recoveryInfo?.indexGeneration).toBeUndefined();
expect(db.stats.indexRebuildDecoded).toBeGreaterThan(0); // corpus re-decoded
expect(db.stats.textRebuildDurationMs).toBeGreaterThan(0); // corpus re-tokenized
const status = db.lifecycleStatus();
// 2000 docs < TEXT_BUILD_WORKER_MIN_DOCS: the rebuild stays in-open
// (staged), so the instance is fully ready when open() returns.
expect(status.state).toBe('ready');
expect(status.path).toEqual(['no-generation', 'generation-load', 'full-rebuild', 'ready']);
expect(status.phases.fullRecoveryMs).toBeGreaterThan(0);
expect(status.phases.walScanMs).toBeGreaterThan(0);
expect(status.phases.walApplyMs).toBeGreaterThan(0);
expect(status.phases.textRebuildMs).toBeGreaterThan(0);
expect(status.textIndexes).toEqual({ ft: 'staged', tri: 'staged' });
assertSeededDb(db, 2000);
await db.close();
});
test('a missing generation with a deferrable corpus reports degraded until the background text build commits', async () => {
const dir = await openTmp('lifecycle-degraded');
let db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
await seedIndexedDb(db, 5000); // >= TEXT_BUILD_WORKER_MIN_DOCS: deferrable
await db.rebuildGeneration();
await db.close();
// No candidate at all: the open starts from 'no-generation'.
await fs.rm(path.join(dir, 'generations'), { recursive: true, force: true });
await fs.rm(path.join(dir, 'CURRENT'), { force: true });
db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' });
expect(db.stats.generationLoads).toBe(0);
const building = db.lifecycleStatus();
expect(building.state).toBe('degraded');
expect(building.path).toEqual(['no-generation', 'full-rebuild', 'degraded']);
expect(building.pendingTextIndexes.sort()).toEqual(['ft', 'tri']);
expect(building.textIndexes).toEqual({ ft: 'deferred', tri: 'deferred' });
expect(db.textIndexBuilding('ft')).toBe(true);
expect(() => db.search('ft', 'hello')).toThrowError(/still building/);
await waitFor(() => db.lifecycleStatus().state === 'ready', 'deferred text builds');
const ready = db.lifecycleStatus();
expect(ready.path).toEqual(['no-generation', 'full-rebuild', 'degraded', 'ready']);
expect(ready.pendingTextIndexes).toEqual([]);
expect(['worker', 'inline']).toContain(ready.textIndexes['ft']);
expect(['worker', 'inline']).toContain(ready.textIndexes['tri']);
expect(ready.phases.textRebuildMs).toBeGreaterThan(0);
expect(db.stats.textDeferredBuilds).toBe(2);
expect(db.stats.textDeferredBuildErrors).toBe(0);
assertSeededDb(db, 5000);
await db.close();
}, 60000);
});