/** * @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 g−1 (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 { 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 g−1; 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) }) })