feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @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.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
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<string, unknown> {
|
|
|
|
|
|
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<string, unknown>; 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([])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
it('maxGenerations cap keeps the most recent record-sets', async () => {
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
await commitWrite(ID_A, 1)
|
|
|
|
|
|
await commitWrite(ID_A, 2)
|
|
|
|
|
|
await commitWrite(ID_A, 3)
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
const result = await store.compact({ maxGenerations: 2 })
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Model-B single-op generation-stamping (per-write history + group-commit)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
describe('Model-B single-op generations', () => {
|
|
|
|
|
|
/** Apply one single-op write as its own generation (live write + buffered history). */
|
|
|
|
|
|
async function singleOpWrite(id: string, version: number): Promise<number> {
|
|
|
|
|
|
const { generation } = await store.commitSingleOp({
|
|
|
|
|
|
touched: { nouns: [id] },
|
|
|
|
|
|
execute: async () => {
|
|
|
|
|
|
await storage.saveNounMetadata(id, metadataFixture(version))
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return generation
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('each single-op write is its own generation, resolvable BEFORE any flush', async () => {
|
|
|
|
|
|
const g1 = await singleOpWrite(ID_A, 1)
|
|
|
|
|
|
const g2 = await singleOpWrite(ID_A, 2)
|
|
|
|
|
|
expect(g1).toBe(1)
|
|
|
|
|
|
expect(g2).toBe(2)
|
|
|
|
|
|
// Counter advanced per write; nothing persisted to disk yet.
|
|
|
|
|
|
expect(store.generation()).toBe(2)
|
|
|
|
|
|
expect(store.committedGeneration()).toBe(0)
|
|
|
|
|
|
// Reads resolve through the in-memory pending tier with NO forced flush:
|
|
|
|
|
|
// the state as-of g1 is v1 (the before-image buffered by g2).
|
|
|
|
|
|
const atG1 = await store.resolveAt('noun', ID_A, g1)
|
|
|
|
|
|
expect(atG1.source).toBe('record')
|
|
|
|
|
|
expect(((atG1 as { metadata: { version: number } }).metadata).version).toBe(1)
|
|
|
|
|
|
// hasCommittedAfter counts pending → a now()-style pin at g1 is historical.
|
|
|
|
|
|
expect(store.hasCommittedAfter(g1)).toBe(true)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-23 11:11:43 -07:00
|
|
|
|
it('range queries (changedBetween / generationsTouching) include un-flushed pending generations', async () => {
|
|
|
|
|
|
// Two single-op writes to different ids, NEITHER flushed (still in the
|
|
|
|
|
|
// pending tier). diff()/since()/history() are built on these, so they must
|
|
|
|
|
|
// see un-flushed single-ops with NO forced flush.
|
|
|
|
|
|
const g1 = await singleOpWrite(ID_A, 1)
|
|
|
|
|
|
const g2 = await singleOpWrite(ID_B, 1)
|
|
|
|
|
|
expect(store.committedGeneration()).toBe(0) // nothing on disk yet
|
|
|
|
|
|
|
|
|
|
|
|
const changed = await store.changedBetween(0, store.generation())
|
|
|
|
|
|
expect(changed.nouns).toEqual([ID_A, ID_B]) // resolves over committed ∪ pending
|
|
|
|
|
|
expect(await store.generationsTouching('noun', ID_A, 0, store.generation())).toEqual([g1])
|
|
|
|
|
|
expect(await store.generationsTouching('noun', ID_B, 0, store.generation())).toEqual([g2])
|
|
|
|
|
|
|
|
|
|
|
|
// …and the range stays identical across the flush (now reading from disk).
|
|
|
|
|
|
await store.flushPendingSingleOps()
|
|
|
|
|
|
expect((await store.changedBetween(0, store.generation())).nouns).toEqual([ID_A, ID_B])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
it('flush persists pending single-op generations (groupCommit-marked) and advances committed', async () => {
|
|
|
|
|
|
await singleOpWrite(ID_A, 1)
|
|
|
|
|
|
await singleOpWrite(ID_A, 2)
|
|
|
|
|
|
await store.flushPendingSingleOps()
|
|
|
|
|
|
expect(store.committedGeneration()).toBe(2)
|
|
|
|
|
|
const delta = await storage.readRawObject(`${GENERATIONS_PREFIX}/2/tx.json`)
|
|
|
|
|
|
expect(delta.groupCommit).toBe(true)
|
|
|
|
|
|
expect(typeof delta.bytes).toBe('number')
|
|
|
|
|
|
// asOf resolution survives the flush (now reading before-images from disk).
|
|
|
|
|
|
const atG1 = await store.resolveAt('noun', ID_A, 1)
|
|
|
|
|
|
expect(((atG1 as { metadata: { version: number } }).metadata).version).toBe(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('🛑 a crash mid group-commit flush DROPS the generation WITHOUT reverting the live write', async () => {
|
|
|
|
|
|
// gen 1: X = v1, flushed (durable).
|
|
|
|
|
|
await singleOpWrite(ID_A, 1)
|
|
|
|
|
|
await store.flushPendingSingleOps()
|
|
|
|
|
|
expect(store.committedGeneration()).toBe(1)
|
|
|
|
|
|
|
|
|
|
|
|
// gen 2: X = v2 acknowledged to canonical storage; its history is buffered.
|
|
|
|
|
|
await singleOpWrite(ID_A, 2)
|
|
|
|
|
|
expect((await storage.readNounRaw(ID_A)).metadata).toMatchObject({ version: 2 })
|
|
|
|
|
|
|
|
|
|
|
|
// Crash before the manifest rename of the group-commit flush: the gen-2
|
|
|
|
|
|
// dir (before-image v1) lands on disk; the manifest stays at gen 1.
|
|
|
|
|
|
store.setCommitFaultInjector((phase) => {
|
|
|
|
|
|
if (phase === 'before-manifest-rename') throw new Error('simulated crash mid-flush')
|
|
|
|
|
|
})
|
|
|
|
|
|
await expect(store.flushPendingSingleOps()).rejects.toThrow('simulated crash mid-flush')
|
|
|
|
|
|
store.setCommitFaultInjector(undefined)
|
|
|
|
|
|
expect(await storage.readRawObject(`${GENERATIONS_PREFIX}/2/tx.json`)).not.toBeNull()
|
|
|
|
|
|
expect((await storage.readRawObject(MANIFEST_PATH)).generation).toBe(1)
|
|
|
|
|
|
|
|
|
|
|
|
// Recover on a FRESH store (process restart: in-memory pending is gone).
|
|
|
|
|
|
const reopened = new GenerationStore(storage)
|
|
|
|
|
|
const result = await reopened.open()
|
|
|
|
|
|
expect(result.rolledBackGenerations).toBe(1)
|
|
|
|
|
|
expect(reopened.committedGeneration()).toBe(1)
|
|
|
|
|
|
|
|
|
|
|
|
// THE CORRUPTION TRAP: drop-without-restore — the acknowledged live write
|
|
|
|
|
|
// (v2) is NOT reverted to the before-image (v1). Only the crashed flush
|
|
|
|
|
|
// window's HISTORY is lost; live data stays correct.
|
|
|
|
|
|
expect((await storage.readNounRaw(ID_A)).metadata).toMatchObject({ version: 2 })
|
|
|
|
|
|
// The orphan dir is gone and the dropped generation number is never reissued.
|
|
|
|
|
|
expect(await storage.readRawObject(`${GENERATIONS_PREFIX}/2/tx.json`)).toBeNull()
|
|
|
|
|
|
expect(reopened.generation()).toBeGreaterThanOrEqual(2)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('a clean reopen replays committed single-op history (asOf survives restart)', async () => {
|
|
|
|
|
|
await singleOpWrite(ID_A, 1)
|
|
|
|
|
|
await singleOpWrite(ID_A, 2)
|
|
|
|
|
|
await store.flushPendingSingleOps()
|
|
|
|
|
|
|
|
|
|
|
|
const reopened = new GenerationStore(storage)
|
|
|
|
|
|
await reopened.open()
|
|
|
|
|
|
expect(reopened.committedGeneration()).toBe(2)
|
|
|
|
|
|
const atG1 = await reopened.resolveAt('noun', ID_A, 1)
|
|
|
|
|
|
expect(((atG1 as { metadata: { version: number } }).metadata).version).toBe(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
// Retention CAPS (Model-B `retention` knob)
|
|
|
|
|
|
// ==========================================================================
|
|
|
|
|
|
describe('retention caps', () => {
|
|
|
|
|
|
async function manyGens(n: number): Promise<void> {
|
|
|
|
|
|
for (let v = 1; v <= n; v++) await commitWrite(ID_A, v)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('maxBytes reclaims the oldest generations until total history fits the budget', async () => {
|
|
|
|
|
|
await manyGens(5)
|
|
|
|
|
|
const total = await store.historyBytes()
|
|
|
|
|
|
expect(total).toBeGreaterThan(0)
|
|
|
|
|
|
const perGen = total / 5
|
|
|
|
|
|
const budget = Math.floor(perGen * 2.5)
|
|
|
|
|
|
const result = await store.compact({ maxBytes: budget })
|
|
|
|
|
|
expect(result.removedGenerations).toBeGreaterThan(0)
|
|
|
|
|
|
expect(await store.historyBytes()).toBeLessThanOrEqual(budget)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('maxAge keeps generations within the window and reclaims older ones', async () => {
|
|
|
|
|
|
await manyGens(3)
|
|
|
|
|
|
// A wide window keeps everything.
|
|
|
|
|
|
expect((await store.compact({ maxAge: 60_000 })).removedGenerations).toBe(0)
|
|
|
|
|
|
// Advance the clock 10s past the commits, then a 1s window reclaims all
|
|
|
|
|
|
// three (fake timers make the age comparison deterministic).
|
|
|
|
|
|
vi.useFakeTimers()
|
|
|
|
|
|
try {
|
|
|
|
|
|
vi.setSystemTime(Date.now() + 10_000)
|
|
|
|
|
|
expect((await store.compact({ maxAge: 1_000 })).removedGenerations).toBe(3)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
vi.useRealTimers()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('no caps reclaims every unpinned generation; a pin protects newer ones', async () => {
|
|
|
|
|
|
await manyGens(3)
|
|
|
|
|
|
store.pin(2) // protects generations > 2 (i.e. gen 3) from reclamation
|
|
|
|
|
|
const result = await store.compact()
|
|
|
|
|
|
// gens 1 and 2 are ≤ the pin → reclaimable; gen 3 is pin-protected.
|
|
|
|
|
|
expect(result.removedGenerations).toBe(2)
|
|
|
|
|
|
store.release(2)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
feat(8.0): generational MVCC storage + Datomic-style Db API (now/transact/asOf/with/persist)
One mechanism replaces the COW and versioning subsystems: immutable
generation-stamped records behind a Datomic-style database value.
Record layer (src/db/generationStore.ts):
- Monotonic generation counter in _system/generation.json; bumped once per
transact() commit and once per single-operation write (storage hook), so
brain.generation() is always a meaningful watermark.
- Commit protocol: stage before-images + tx.json delta -> fsync -> execute
batch via TransactionManager -> atomic tmp+rename of _system/manifest.json
(the rename IS the commit point) -> append _system/tx-log.jsonl.
- Crash recovery on open rolls uncommitted generations back byte-identically
and forces an index rebuild; refcounted pins gate compactHistory(), which
records a horizon (asOf below it throws GenerationCompactedError).
Db API:
- Db: get/find/search/related pinned at a generation, with() speculative
overlays, since() diffs, persist() hard-link-farm snapshots, timestamp,
generation, release() + FinalizationRegistry backstop.
- Brainy: now() O(1) pin, transact(ops, {meta, ifAtGeneration}) atomic batch
(GenerationConflictError CAS), asOf(generation|Date|path), restore(path,
{confirm}), compactHistory(), generation(), static open() + load().
- get()/metadata find()/related() are fully correct at any reachable pinned
generation; index-accelerated queries at historical generations throw
NotYetSupportedAtHistoricalGenerationError - never silently-wrong results.
- VersionedIndexProvider (generation/isGenerationVisible/pin/release) in
plugin.ts: feature-detected, balanced pin/release in lockstep with Db
lifecycle, post-commit applier + replay-gap model documented.
Storage primitives (BaseStorage + filesystem/memory adapters): raw-object
read/write/list/remove, fsync barrier, noun/verb raw before-image capture,
tx-log append, snapshotToDirectory (hard-link farm; byte-copy fallback and
append-in-place exceptions), restoreFromDirectory + derived-state reload.
Proof suite (tests/integration/db-mvcc.test.ts + tests/unit/db/): isolation
across 200 mutations, batch atomicity under injected execution failure,
ifAtGeneration CAS, snapshot immunity to source mutation, compaction safety
under pins, with() overlay isolation, generation monotonicity across
reopen, crash consistency through the real recovery path, and versioned
provider pin/release balance. Design record in
docs/ADR-001-generational-mvcc.md.
2026-06-10 14:14:07 -07:00
|
|
|
|
})
|