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.
This commit is contained in:
parent
373a48122d
commit
2c84f86815
10 changed files with 1069 additions and 37 deletions
381
tests/integration/db-temporal.test.ts
Normal file
381
tests/integration/db-temporal.test.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
/**
|
||||
* @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: 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 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 -------------------------------------------------------------
|
||||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue