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

382 lines
17 KiB
TypeScript
Raw 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.
* 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)
})
})