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

395 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 (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).
* 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 (Model-B) ---------------------------------------------------
it('granularity: single-operation writes ARE versioned and visible 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)
// Model-B: a single-op write is its OWN immutable generation — logged,
// diffable, and time-travelable, exactly like a transact() of one op.
await brain.update({ id: a, metadata: { v: 2 } })
// 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`.
const d = await brain.diff(r1.generation, brain.generation())
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()
})
// 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({ maxGenerations: 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)
})
})