brainy/tests/integration/db-temporal.test.ts
David Snelling 2c84f86815 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

381 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @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 oldest→newest.
* 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.
* 7. Granularity: single-operation writes (outside transact()) are invisible to the
* temporal verbs (transact commits only).
* 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()
})
// 7. Granularity -------------------------------------------------------------
it('granularity: single-operation writes (outside transact) are invisible to the temporal verbs', async () => {
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)
// A single-op write bumps the counter but writes no generation record/log entry.
await brain.update({ id: a, metadata: { v: 2 } })
expect((await brain.transactionLog()).length).toBe(1) // unchanged — not logged
const d = await brain.diff(r1.generation, brain.generation())
expect(d.added.nouns).toEqual([]) // the single-op update is not a recorded change
expect(d.modified.nouns).toEqual([])
})
// 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
await brain.compactHistory({ retainGenerations: 2 }) // raise the horizon
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)
})
})