/** * @module tests/unit/db/generationStore * @description Unit tests for the generational MVCC record layer * (`src/db/generationStore.ts`) against a real `MemoryStorage` adapter — * counter monotonicity, the commit protocol (staging → execute → manifest * rename → tx-log), point-in-time resolution from before-images, pin * refcounting, compaction retention rules, CAS conflicts, timestamp * resolution, and crash rollback of uncommitted generations. */ import { describe, it, expect, beforeEach } from 'vitest' import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' import { GenerationStore, GENERATIONS_PREFIX, MANIFEST_PATH } from '../../../src/db/generationStore.js' import { GenerationCompactedError, GenerationConflictError } from '../../../src/db/errors.js' import { NounType } from '../../../src/types/graphTypes.js' /** Stored-metadata fixture in the canonical shape the live write paths use. */ function metadataFixture(version: number): Record { return { noun: NounType.Document, subtype: 'note', data: `payload-v${version}`, version, createdAt: 1000, updatedAt: 1000 + version, _rev: version } } // Entity ids must be UUID-shaped (the sharded storage layout derives the // shard from the UUID hex). Fixed values keep assertions deterministic; // suffix ordering (…aa < …bb < …cc) matches `changedBetween`'s sorted output. const ID_A = '00000000-0000-4000-8000-0000000000aa' const ID_B = '00000000-0000-4000-8000-0000000000bb' const ID_C = '00000000-0000-4000-8000-0000000000cc' const ID_SOLO = '00000000-0000-4000-8000-0000000000d0' const ID_LATER = '00000000-0000-4000-8000-0000000000e0' describe('db/GenerationStore', () => { let storage: MemoryStorage let store: GenerationStore beforeEach(async () => { storage = new MemoryStorage() await storage.init() store = new GenerationStore(storage) await store.open() }) /** Commit one transaction that writes `metadata` for `id`. */ async function commitWrite( id: string, version: number, options?: { meta?: Record; ifAtGeneration?: number } ): Promise<{ generation: number; timestamp: number }> { return store.commitTransaction({ touched: { nouns: [id], verbs: [] }, meta: options?.meta, ifAtGeneration: options?.ifAtGeneration, execute: async () => { await storage.saveNounMetadata(id, metadataFixture(version)) } }) } describe('generation counter', () => { it('starts at 0 on a fresh store', () => { expect(store.generation()).toBe(0) expect(store.committedGeneration()).toBe(0) }) it('advances by exactly one per committed transaction', async () => { await commitWrite(ID_A, 1) expect(store.generation()).toBe(1) await commitWrite(ID_A, 2) expect(store.generation()).toBe(2) expect(store.committedGeneration()).toBe(2) }) it('bumps on single-operation writes via the storage hook (suppressed inside transact)', async () => { // Single-op write outside any transaction → hook bumps. await storage.saveNounMetadata(ID_SOLO, metadataFixture(1)) expect(store.generation()).toBe(1) expect(store.committedGeneration()).toBe(0) // no transact committed // A transact batch writing twice bumps ONCE (the batch is one generation). await store.commitTransaction({ touched: { nouns: [ID_A, ID_B], verbs: [] }, execute: async () => { await storage.saveNounMetadata(ID_A, metadataFixture(1)) await storage.saveNounMetadata(ID_B, metadataFixture(1)) } }) expect(store.generation()).toBe(2) }) it('survives close + reopen monotonically', async () => { await commitWrite(ID_A, 1) await storage.saveNounMetadata(ID_SOLO, metadataFixture(1)) // single-op bump const before = store.generation() await store.close() const reopened = new GenerationStore(storage) await reopened.open() expect(reopened.generation()).toBeGreaterThanOrEqual(before) expect(reopened.committedGeneration()).toBe(1) }) }) describe('CAS (ifAtGeneration)', () => { it('commits when the expectation matches and rejects with expected/actual when stale', async () => { await commitWrite(ID_A, 1) const pinned = store.generation() await commitWrite(ID_A, 2, { ifAtGeneration: pinned }) // fresh → commits expect(store.generation()).toBe(pinned + 1) try { await commitWrite(ID_A, 3, { ifAtGeneration: pinned }) // stale → rejects expect.unreachable('should have thrown GenerationConflictError') } catch (err) { expect(err).toBeInstanceOf(GenerationConflictError) expect((err as GenerationConflictError).expected).toBe(pinned) expect((err as GenerationConflictError).actual).toBe(pinned + 1) } expect(store.generation()).toBe(pinned + 1) // nothing moved }) }) describe('failed transactions', () => { it('removes the staging directory and returns the generation reservation', async () => { await commitWrite(ID_A, 1) const before = store.generation() await expect( store.commitTransaction({ touched: { nouns: [ID_A], verbs: [] }, execute: async () => { throw new Error('boom mid-batch') } }) ).rejects.toThrow('boom mid-batch') expect(store.generation()).toBe(before) const leftovers = await storage.listRawObjects(`${GENERATIONS_PREFIX}/${before + 1}`) expect(leftovers).toEqual([]) }) }) describe('point-in-time resolution', () => { it('resolves untouched ids to the live fast path', async () => { await commitWrite(ID_A, 1) const pinned = store.generation() await commitWrite(ID_B, 1) // later commit touches a DIFFERENT id expect(await store.resolveAt('noun', ID_A, pinned)).toEqual({ source: 'current' }) }) it('resolves changed ids from the first before-image after the pin', async () => { await commitWrite(ID_A, 1) const pinned = store.generation() await commitWrite(ID_A, 2) await commitWrite(ID_A, 3) const resolved = await store.resolveAt('noun', ID_A, pinned) expect(resolved.source).toBe('record') if (resolved.source === 'record') { expect(resolved.metadata.version).toBe(1) // state as of the pin } }) it('resolves ids created after the pin as absent', async () => { await commitWrite(ID_A, 1) const pinned = store.generation() await commitWrite(ID_LATER, 1) expect(await store.resolveAt('noun', ID_LATER, pinned)).toEqual({ source: 'absent' }) }) it('changedBetween reports the ids touched in the interval', async () => { await commitWrite(ID_A, 1) const from = store.generation() await commitWrite(ID_B, 1) await commitWrite(ID_C, 1) const to = store.generation() const changed = await store.changedBetween(from, to) expect(changed.nouns).toEqual([ID_B, ID_C]) expect(changed.verbs).toEqual([]) expect(changed.fromGeneration).toBe(from) expect(changed.toGeneration).toBe(to) }) }) describe('timestamp resolution + tx-log', () => { it('records meta in the tx-log and resolves timestamps to generations', async () => { const t0 = Date.now() - 10 const first = await commitWrite(ID_A, 1, { meta: { author: 'unit-test' } }) // Distinct commit timestamps: resolution picks the NEWEST generation // committed at-or-before the instant, so same-millisecond commits tie. await new Promise((resolve) => setTimeout(resolve, 5)) const second = await commitWrite(ID_A, 2) const lines = await storage.readTxLogLines() expect(lines.length).toBe(2) const firstEntry = JSON.parse(lines[0]) expect(firstEntry.generation).toBe(first.generation) expect(firstEntry.meta).toEqual({ author: 'unit-test' }) expect((await store.resolveTimestamp(t0)).generation).toBe(0) expect((await store.resolveTimestamp(first.timestamp)).generation).toBe(first.generation) expect((await store.resolveTimestamp(Date.now() + 1000)).generation).toBe( second.generation ) }) }) describe('pins + compaction', () => { it('refcounts pins and never reclaims records a live pin needs', async () => { await commitWrite(ID_A, 1) const pinned = store.generation() store.pin(pinned) store.pin(pinned) // refcount 2 await commitWrite(ID_A, 2) await commitWrite(ID_A, 3) // Pin at G protects every record-set ABOVE G (resolution reads // before-images from generations strictly greater than the pin). await store.compact() const resolved = await store.resolveAt('noun', ID_A, pinned) expect(resolved.source).toBe('record') if (resolved.source === 'record') { expect(resolved.metadata.version).toBe(1) } store.release(pinned) await store.compact() // Still one live pin — records above it still resolve correctly. const stillResolved = await store.resolveAt('noun', ID_A, pinned) expect(stillResolved.source).toBe('record') store.release(pinned) const result = await store.compact() expect(result.removedGenerations).toBeGreaterThan(0) const remaining = await storage.listRawObjects(GENERATIONS_PREFIX) expect(remaining).toEqual([]) }) it('retainGenerations keeps the most recent record-sets', async () => { await commitWrite(ID_A, 1) await commitWrite(ID_A, 2) await commitWrite(ID_A, 3) const result = await store.compact({ retainGenerations: 2 }) expect(result.removedGenerations).toBe(1) expect(result.horizon).toBe(1) // Removing record-set N breaks pins BELOW N (their reads need N's // before-images) — the horizon itself stays reachable: state at the // horizon resolves from the retained record-sets above it. expect(() => store.assertReachable(0)).toThrow(GenerationCompactedError) expect(store.assertReachable(1)).toBe(1) expect(store.assertReachable(2)).toBe(2) }) it('rejects future and negative generations', async () => { await commitWrite(ID_A, 1) expect(() => store.assertReachable(99)).toThrow(RangeError) expect(() => store.assertReachable(-1)).toThrow(RangeError) expect(() => store.assertReachable(1.5)).toThrow(RangeError) }) }) describe('crash recovery', () => { it('rolls back an uncommitted generation on open (manifest rename is the commit point)', async () => { await commitWrite(ID_A, 1) // Simulate a crash between record staging and the manifest rename: a // throwing fault injector skips the abort cleanup, exactly as a dead // process would, leaving canonical files mutated and the staging // directory present — but the manifest still at the prior generation. store.setCommitFaultInjector((phase) => { if (phase === 'before-manifest-rename') { throw new Error('simulated crash') } }) await expect(commitWrite(ID_A, 2)).rejects.toThrow('simulated crash') store.setCommitFaultInjector(undefined) // The canonical file currently holds the EXECUTED (uncommitted) state. const dirty = await storage.readNounRaw(ID_A) expect((dirty.metadata as { version: number }).version).toBe(2) // Recovery on the next open restores the before-image byte-identically. const reopened = new GenerationStore(storage) const openResult = await reopened.open() expect(openResult.rolledBackGenerations).toBe(1) const repaired = await storage.readNounRaw(ID_A) expect((repaired.metadata as { version: number }).version).toBe(1) expect(reopened.committedGeneration()).toBe(1) // The crashed generation number is never reused. expect(reopened.generation()).toBeGreaterThanOrEqual(2) }) it('keeps a transaction whose manifest rename landed (tx-log append is advisory)', async () => { await commitWrite(ID_A, 1) store.setCommitFaultInjector((phase) => { if (phase === 'after-manifest-rename') { throw new Error('simulated crash after commit point') } }) await expect(commitWrite(ID_A, 2)).rejects.toThrow('simulated crash after commit point') store.setCommitFaultInjector(undefined) const reopened = new GenerationStore(storage) const openResult = await reopened.open() expect(openResult.rolledBackGenerations).toBe(0) expect(reopened.committedGeneration()).toBe(2) const kept = await storage.readNounRaw(ID_A) expect((kept.metadata as { version: number }).version).toBe(2) const manifest = await storage.readRawObject(MANIFEST_PATH) expect(manifest.generation).toBe(2) }) }) })