brainy/tests/integration/db-temporal.test.ts

396 lines
17 KiB
TypeScript
Raw Permalink Normal View History

feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
/**
* @module tests/integration/db-temporal
* @description Proof suite for Brainy 8.0's temporal RANGE verbs, built on the
* single-point `asOf()` foundation (proven byte-identical in db-mvcc). Each test
* PROVES a stated guarantee:
*
* 1. asOf(g, { exclusive }) pins g1 (strict-before); exclusive of the first
* commit is the empty generation 0, never a RangeError.
* 2. since() accepts Db | generation | Date equivalently, with the direction
* guard; the lower bound is EXCLUSIVE.
* 3. transactionLog({ from, to }) windows INCLUSIVELY on both ends (the contrast
* to since's exclusive lower bound) and applies `limit` AFTER the window.
* 4. diff() EARNS its name: it does NOT return raw changed-ids an id created
* and removed within the interval lands in NO bucket; added/removed/modified
* are classified by existence + a stable value comparison.
* 5. history() ties to the trusted asOf path: every version's value deep-equals
* asOf(version.generation).get(id), a removal is a null value, order is oldestnewest.
* 6. Composition: "type T changed in (g1, g2]" computed two ways (diff ids filtered
* to T, vs asOf(g2).find({type:T}) changed-ids) AGREE including a where filter.
refactor(8.0): API-surface + quality polish from the readiness audit - find() search-mode: collapsed the two overlapping options to one canonical `searchMode: SearchMode` (the redundant `mode` alias was silently ignored on the primary find() path while honored on the historical path — a footgun) and removed the unwired `explain?` FindParams field (never read by find()). - Documentation accuracy on the public type surface: entity ids documented as UUID v7 (auto) / v5 (natural key), not v4; dropped the stale "Brainy 3.0" banners and "backward compatibility" hedging on the fresh-8.0 Result type; documented the via/type alias; removed the dead GraphConstraints.bidirectional; fixed the no-op `EmbeddedGraphVerb = Omit<GraphVerb,'source'>` to omit the real sourceId; corrected the GraphIndexProvider.isInitialized JSDoc; documented removeMany's per-chunk generation granularity; JSDoc'd the public VFS surface. - Honest perf comments in source: removed unmeasured billion-scale figures from the LSMTree / graphAdjacencyIndex headers (the JS fallback is in-memory after open; native is the scale path). - Robustness: getVerbMetadata propagates read errors symmetrically with getNounMetadata; the find() egress integrity-guard keeps a row when the JS matcher doesn't implement an operator the provider already matched on; hardened the boundary-no-native CI guard to catch side-effect imports + re-exports. - Tests: de-theatricalized a relateMany test to assert the real contract; fixed a stale Model-B header. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:04:19 -07:00
* 7. Granularity (Model-B): single-operation writes (outside transact()) are their
* own immutable generation logged, diffable, and time-travelable, exactly like
* a transact() of one op (the test body below proves this).
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
* 8. Compaction policy contrast: diff/since THROW below the horizon; history TRUNCATES
* to it. (Locked so a refactor can't unify them incorrectly.)
*
* Mirrors the db-mvcc harness: explicit vectors (no embedder), deterministic
* UUID-shaped ids, afterEach teardown. db-mvcc itself re-proves asOf is unchanged.
*/
import { describe, it, expect, afterEach } from 'vitest'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { Brainy } from '../../src/brainy.js'
import { GenerationCompactedError } from '../../src/db/errors.js'
import type { GenerationStore } from '../../src/db/generationStore.js'
import { NounType } from '../../src/types/graphTypes.js'
/** Deterministic 384-dim vector so no test ever invokes the embedder. */
function vec(seed: number): number[] {
return Array.from({ length: 384 }, (_, i) => ((seed * 31 + i * 7) % 100) / 100)
}
/** Map a label to a deterministic UUID-shaped id (storage shards by UUID hex). */
function uid(label: string): string {
let h1 = 0x811c9dc5
for (let i = 0; i < label.length; i++) h1 = Math.imul(h1 ^ label.charCodeAt(i), 0x01000193) >>> 0
let h2 = 0xdeadbeef
for (let i = label.length - 1; i >= 0; i--) h2 = Math.imul(h2 ^ label.charCodeAt(i), 0x85ebca6b) >>> 0
const hex = h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')
return `00000000-0000-4000-8000-${hex.slice(0, 12)}`
}
function generationStoreOf(brain: Brainy): GenerationStore {
return (brain as unknown as { generationStore: GenerationStore }).generationStore
}
describe('8.0 Db API — temporal range verbs', () => {
const brains: Brainy[] = []
const tempDirs: string[] = []
async function openMemoryBrain(): Promise<Brainy> {
const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
await brain.init()
brains.push(brain)
return brain
}
afterEach(async () => {
for (const brain of brains.splice(0)) {
try {
await brain.close()
} catch {
/* already closed */
}
}
for (const dir of tempDirs.splice(0)) {
await fs.promises.rm(dir, { recursive: true, force: true })
}
})
// 1. asOf exclusive ----------------------------------------------------------
it('asOf(g, { exclusive }) pins g1; exclusive clamps at gen 0 without a RangeError', async () => {
const brain = await openMemoryBrain()
const a = uid('exc-a')
const rAdd = await brain.transact([
{ op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } }
])
const gAdd = rAdd.generation
await rAdd.release()
const rUpd = await brain.transact([{ op: 'update', id: a, metadata: { v: 2 } }])
const gUpd = rUpd.generation
await rUpd.release()
// exclusive of gUpd = the state immediately before it (= the add generation, v:1)
const before = await brain.asOf(gUpd, { exclusive: true })
expect(before.generation).toBe(gUpd - 1)
expect((await before.get(a))?.metadata?.v).toBe(1)
await before.release()
const at = await brain.asOf(gUpd) // inclusive
expect((await at.get(a))?.metadata?.v).toBe(2)
await at.release()
// exclusive clamps at 0 (never a RangeError): strict-before generation 1 is the
// empty pre-commit state — `a` does not exist there.
const zero = await brain.asOf(1, { exclusive: true })
expect(zero.generation).toBe(0)
expect(await zero.get(a)).toBeNull()
await zero.release()
})
// 2. since 3-forms + direction guard ----------------------------------------
it('since() accepts Db | generation | Date equivalently; lower bound is exclusive; direction guarded', async () => {
const brain = await openMemoryBrain()
const a = uid('since-a')
const b = uid('since-b')
const r1 = await brain.transact([
{ op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: {} }
])
const g1 = r1.generation // a's add generation
await brain.transact([
{ op: 'add', id: b, type: NounType.Document, data: 'b', vector: vec(2), metadata: {} }
])
await brain.transact([{ op: 'update', id: a, metadata: { x: 1 } }])
const now = brain.now()
const viaDb = await now.since(r1) // changes in (g1, now]
const viaGen = await now.since(g1)
const viaEpoch = await now.since(new Date(0)) // resolves to gen 0 → (0, now]
// since(db) === since(db.generation); the EXCLUSIVE lower bound omits a's own add@g1
expect(viaDb).toEqual(viaGen)
expect(viaDb.fromGeneration).toBe(g1)
expect(viaDb.nouns).toEqual([a, b].sort()) // a (updated after g1) + b (added after g1)
expect(viaEpoch.nouns).toEqual([a, b].sort()) // (0, now] also includes a's creation, still {a, b}
// direction guard: an older view cannot be `since` a newer lower bound
const older = await brain.asOf(1)
await expect(older.since(now)).rejects.toBeInstanceOf(RangeError)
await older.release()
await now.release()
await r1.release()
})
// 3. transactionLog window (inclusive both ends) + limit-after-window --------
it('transactionLog({ from, to }) windows inclusively on both ends and applies limit last', async () => {
const brain = await openMemoryBrain()
const gens: number[] = []
for (let v = 1; v <= 5; v++) {
const r = await brain.transact([
{
op: 'add',
id: uid(`log-${v}`),
type: NounType.Document,
data: `d${v}`,
vector: vec(v),
metadata: { v }
}
])
gens.push(r.generation) // 5 consecutive generations, one log entry each
await r.release()
}
const all = await brain.transactionLog()
expect(all.map((e) => e.generation)).toEqual([...gens].reverse()) // newest first
// INCLUSIVE both ends — gens[1] AND gens[3] are present (contrast since's exclusive lower).
const windowed = await brain.transactionLog({ from: gens[1], to: gens[3] })
expect(windowed.map((e) => e.generation)).toEqual([gens[3], gens[2], gens[1]])
// limit applies AFTER the window (newest first within the window)
const limited = await brain.transactionLog({ from: gens[1], to: gens[3], limit: 2 })
expect(limited.map((e) => e.generation)).toEqual([gens[3], gens[2]])
// Inclusivity CONTRAST vs since: transactionLog INCLUDES `from`; since EXCLUDES it.
const logFrom = await brain.transactionLog({ from: gens[1] }) // [gens[1], …]
expect(logFrom.map((e) => e.generation).includes(gens[1])).toBe(true)
const sinceFrom = await brain.now().since(gens[1]) // (gens[1], …] — exclusive lower
expect(sinceFrom.fromGeneration).toBe(gens[1])
await expect(
brain.transactionLog({ from: gens[3], to: gens[1] })
).rejects.toBeInstanceOf(RangeError)
})
// 4. diff EARNS its name -----------------------------------------------------
it('diff() classifies added/removed/modified — and an id created+removed within the interval is in NO bucket', async () => {
const brain = await openMemoryBrain()
const persistent = uid('diff-persistent')
const created = uid('diff-created')
const removed = uid('diff-removed')
const ephemeral = uid('diff-ephemeral')
// gen 1: baseline — persistent + removed exist
await (
await brain.transact([
{ op: 'add', id: persistent, type: NounType.Document, data: 'p', vector: vec(1), metadata: { v: 1 } },
{ op: 'add', id: removed, type: NounType.Document, data: 'r', vector: vec(2), metadata: { v: 1 } }
])
).release()
const g1 = brain.generation()
// gen 2: create `ephemeral` and `created`; modify `persistent`; delete `removed`
await (
await brain.transact([
{ op: 'add', id: ephemeral, type: NounType.Document, data: 'e', vector: vec(3), metadata: { v: 1 } },
{ op: 'add', id: created, type: NounType.Document, data: 'c', vector: vec(4), metadata: { v: 1 } },
{ op: 'update', id: persistent, metadata: { v: 2 } },
{ op: 'remove', id: removed }
])
).release()
// gen 3: delete `ephemeral` (so it was BORN and DIED inside (g1, g3])
await (await brain.transact([{ op: 'remove', id: ephemeral }])).release()
const g3 = brain.generation()
const d = await brain.diff(g1, g3)
expect(d.added.nouns).toEqual([created]) // exists at g3, not g1
expect(d.removed.nouns).toEqual([removed]) // exists at g1, not g3
expect(d.modified.nouns).toEqual([persistent]) // exists both, value changed
// EARNS-ITS-NAME: `ephemeral` was touched (raw changedBetween would list it) but is
// absent at BOTH endpoints → it appears in NONE of the buckets.
expect(d.added.nouns).not.toContain(ephemeral)
expect(d.removed.nouns).not.toContain(ephemeral)
expect(d.modified.nouns).not.toContain(ephemeral)
// Orientation: diff(g3, g1) flips added/removed.
const rev = await brain.diff(g3, g1)
expect(rev.added.nouns).toEqual([removed])
expect(rev.removed.nouns).toEqual([created])
expect(rev.modified.nouns).toEqual([persistent])
// diff(g, g) is empty.
const empty = await brain.diff(g3, g3)
expect(empty.added.nouns).toEqual([])
expect(empty.modified.nouns).toEqual([])
})
// 5. history cross-checks the trusted asOf path ------------------------------
it('history() — each version deep-equals asOf(version.generation).get(id); removal is a null value', async () => {
const brain = await openMemoryBrain()
const a = uid('hist-a')
await (
await brain.transact([
{ op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } }
])
).release() // gen 1
await (await brain.transact([{ op: 'update', id: a, metadata: { v: 2 } }])).release() // gen 2
await (await brain.transact([{ op: 'update', id: a, metadata: { v: 3 } }])).release() // gen 3
await (await brain.transact([{ op: 'remove', id: a }])).release() // gen 4
const h = await brain.history(a)
expect(h.kind).toBe('noun')
// oldest → newest, ending in a removal (null value)
const vs = h.versions
expect(vs.length).toBeGreaterThanOrEqual(4)
for (let i = 1; i < vs.length; i++) {
expect(vs[i].generation).toBeGreaterThan(vs[i - 1].generation) // strictly ascending
}
expect(vs[vs.length - 1].value).toBeNull() // removed at the end
// Each version ties to the trusted asOf path.
for (const v of vs) {
const db = await brain.asOf(v.generation)
const viaAsOf = await db.get(a)
await db.release()
if (v.value === null) expect(viaAsOf).toBeNull()
else expect(viaAsOf).toEqual(v.value)
}
// The distinct user-visible values appear in order.
const versionsWithValue = vs.filter((v) => v.value !== null)
expect(versionsWithValue.map((v) => (v.value as any).metadata?.v)).toEqual([1, 2, 3])
// Unknown id → empty history (no throw).
const unknown = await brain.history(uid('hist-nobody'))
expect(unknown.versions).toEqual([])
})
// 6. Composition proof -------------------------------------------------------
it('composition: "type T changed in (g1, g2]" agrees computed via diff vs via asOf(g2).find ∩ changed', async () => {
const brain = await openMemoryBrain()
const p1 = uid('comp-p1')
const p2 = uid('comp-p2')
const p3 = uid('comp-p3')
const d1 = uid('comp-d1')
await (
await brain.transact([
{ op: 'add', id: p1, type: NounType.Person, data: 'p1', vector: vec(1), metadata: { team: 'x' } },
{ op: 'add', id: d1, type: NounType.Document, data: 'd1', vector: vec(2), metadata: { team: 'x' } }
])
).release()
const g1 = brain.generation()
await (
await brain.transact([
{ op: 'add', id: p2, type: NounType.Person, data: 'p2', vector: vec(3), metadata: { team: 'x' } },
{ op: 'update', id: d1, metadata: { team: 'y' } }
])
).release()
await (
await brain.transact([
{ op: 'add', id: p3, type: NounType.Person, data: 'p3', vector: vec(4), metadata: { team: 'z' } }
])
).release()
const g2 = brain.generation()
// Way 1 — from diff: ids that came-into-being or changed in (g1, g2], filtered to Person at g2.
const d = await brain.diff(g1, g2)
const changedNouns = [...d.added.nouns, ...d.modified.nouns]
const dbAtG2 = await brain.asOf(g2)
const way1: string[] = []
for (const id of changedNouns) {
const e = await dbAtG2.get(id)
if (e?.type === NounType.Person) way1.push(id)
}
// Way 2 — from find ∩ changed: all Person at g2, intersect with the changed-id set.
const personsAtG2 = await dbAtG2.find({ type: NounType.Person, limit: 100 })
const changedSet = new Set([...d.added.nouns, ...d.removed.nouns, ...d.modified.nouns])
const way2 = personsAtG2.map((r) => r.id).filter((id) => changedSet.has(id))
expect(way1.sort()).toEqual(way2.sort())
expect(way1.sort()).toEqual([p2, p3].sort()) // p2 added, p3 added; p1 unchanged; d1 is a Document
// With a where filter: Person on team 'x' changed in (g1, g2] → just p2.
const personsTeamX = await dbAtG2.find({ type: NounType.Person, where: { team: 'x' }, limit: 100 })
const teamXChanged = personsTeamX.map((r) => r.id).filter((id) => changedSet.has(id))
expect(teamXChanged).toEqual([p2])
await dbAtG2.release()
})
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
// 7. Granularity (Model-B) ---------------------------------------------------
it('granularity: single-operation writes ARE versioned and visible to the temporal verbs', async () => {
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
const brain = await openMemoryBrain()
const a = uid('gran-a')
const r1 = await brain.transact([
{ op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(1), metadata: { v: 1 } }
])
await r1.release()
expect((await brain.transactionLog()).length).toBe(1)
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: a single-op write is its OWN immutable generation — logged,
// diffable, and time-travelable, exactly like a transact() of one op.
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
await brain.update({ id: a, metadata: { v: 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
// The single-op update appended a generation/log entry.
expect((await brain.transactionLog()).length).toBe(2)
expect(brain.generation()).toBe(r1.generation + 1)
// diff sees the single-op update as a modification of `a`.
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
const d = await brain.diff(r1.generation, brain.generation())
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
expect(d.added.nouns).toEqual([])
expect(d.modified.nouns).toEqual([a])
// And asOf() resolves both states: v=1 at the transact gen, v=2 at the single-op gen.
const atTransact = await brain.asOf(r1.generation)
const atSingleOp = await brain.asOf(brain.generation())
expect((await atTransact.get(a))?.metadata?.v).toBe(1)
expect((await atSingleOp.get(a))?.metadata?.v).toBe(2)
await atTransact.release()
await atSingleOp.release()
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
})
// 8. Compaction policy contrast ---------------------------------------------
it('compaction: diff/since THROW below the horizon; history TRUNCATES to it', async () => {
const brain = await openMemoryBrain()
const a = uid('comp-a')
for (let v = 1; v <= 6; v++) {
await (
await brain.transact([
v === 1
? { op: 'add', id: a, type: NounType.Document, data: 'a', vector: vec(v), metadata: { v } }
: { op: 'update', id: a, metadata: { v } }
])
).release()
} // gens 1..6, all pins released
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
await brain.compactHistory({ maxGenerations: 2 }) // raise the horizon
feat(8.0): temporal range verbs — diff, history, since(gen|Date), asOf{exclusive}, transactionLog window asOf() answers "state AT a point"; these answer "what happened BETWEEN two points" and "one entity's whole history", all on the existing generation records. - Extract resolveAsOfGeneration() as the shared gen|Date→{generation,timestamp} chokepoint (reachability + Date semantics identical everywhere). asOf()'s inclusive path is byte-identical — db-mvcc still 25/25. - asOf(target, { exclusive }) — strict-before (resolved−1, clamped at gen 0, never a RangeError). - db.since(Db | generation | Date) — overload; number/Date resolve via the shared resolver (new DbHost.resolveGeneration); EXCLUSIVE lower bound; since(db) === since(db.generation). Same-store guard via Db.belongsToStore. - brain.diff(a, b) → { added, removed, modified } split by nouns/verbs. EARNS its name: candidate set is ONLY changedBetween(gLow,gHigh), each classified by existence at both endpoints + a key-order-insensitive value compare (new src/db/stableEqual.ts) — a touched-but-reverted / born-and-died id is in no bucket. - brain.history(id, { from, to }) → every distinct version oldest→newest, each value === asOf(version.generation).get(id); null = removal; kind auto-detected (throws on UUID-space collision). New store helper generationsTouching(). - brain.transactionLog({ from, to, limit }) — INCLUSIVE generation/Date window (contrast since's exclusive lower bound); limit applied last. - Compaction policy locked: diff/since THROW GenerationCompactedError below the horizon; history TRUNCATES to it. Types DiffResult/HistoryVersion/EntityHistory exported from the package root. Tests: tests/integration/db-temporal.test.ts (8 proofs incl. diff-earns-its-name, history↔asOf cross-check, the composition proof, granularity, compaction contrast) + tests/unit/db/stableEqual.test.ts (6). docs/guides/snapshots-and-time-travel.md + RELEASES updated. 1477 unit + db-temporal 8 + db-mvcc 25 green.
2026-06-19 13:21:02 -07:00
const horizon = generationStoreOf(brain).horizon()
expect(horizon).toBeGreaterThan(0)
// diff / since reaching below the horizon THROW.
await expect(brain.diff(0, 6)).rejects.toBeInstanceOf(GenerationCompactedError)
const now = brain.now()
await expect(now.since(0)).rejects.toBeInstanceOf(GenerationCompactedError)
await now.release()
// history TRUNCATES (no throw) — its versions start at or above the horizon.
const h = await brain.history(a)
expect(h.versions.length).toBeGreaterThan(0)
for (const v of h.versions) expect(v.generation).toBeGreaterThanOrEqual(horizon)
})
})