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 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.
|
2026-06-29 10:04:19 -07:00
|
|
|
|
* 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).
|
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
|
|
|
|
* 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()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
// 7. Granularity (Model-B) ---------------------------------------------------
|
|
|
|
|
|
it('granularity: single-operation writes ARE versioned and visible to the temporal verbs', async () => {
|
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
|
|
|
|
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)
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
// Model-B: a single-op write is its OWN immutable generation — logged,
|
|
|
|
|
|
// diffable, and time-travelable, exactly like a transact() of one op.
|
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
|
|
|
|
await brain.update({ id: a, metadata: { v: 2 } })
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
// 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`.
|
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
|
|
|
|
const d = await brain.diff(r1.generation, brain.generation())
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
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()
|
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
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
feat(8.0): Model-B per-write generation-stamping + adaptive retention knob
Every write — transact() AND single-op add/update/remove/relate — is now its
own immutable generation (Model-B), so a now() pin always freezes and
asOf/since/diff/history/transactionLog reflect single-ops exactly like
transacts. Closes the Model-A hole where pins did not freeze against single-op
writes.
Generation-stamping:
- GenerationStore.commitSingleOp: a one-operation commitTransaction with
deferred durability. Wired into add/update/remove/relate/updateRelation/
unrelate + removeMany (the *Many and VFS paths delegate to these).
- Async group-commit (flushPendingSingleOps): the live write is acknowledged
immediately; its before-image is buffered in an in-memory pending tier that
resolveAt/chains/changedBetween/tx-log read like on-disk generations, so the
synchronous now() freezes with no forced flush. One fsync per window
(triggers: size / 50ms timer / flush / close / transact / compactHistory).
- Crash recovery is drop-without-restore for group-commit generations (marked
groupCommit:true): a crash mid-flush discards the partial generation and
never restores its before-images, which would otherwise revert the
already-acknowledged live write.
- Init-time infrastructure (the VFS root) is the un-versioned generation-0
baseline: a fresh brain reports generation()===0 and an empty
transactionLog(); the first user write is generation 1.
- Historical find()/related() overlay bound is the full reserved watermark
(generation()), so un-flushed single-op writes are overlaid too.
Retention knob:
- config `history` -> `retention`: 'all' | 'adaptive' |
{ maxGenerations?, maxAge?, maxBytes?, budgetBytes?, autoCompact? }; unset ->
adaptive (disk/RAM pressure, zero-config). CompactHistoryOptions floors ->
caps (retainGenerations->maxGenerations, retainMs->maxAge, +maxBytes):
reclaim oldest-unpinned while ANY cap is exceeded; pins always exempt.
- brain.setRetentionBudget(bytes) drives the adaptive byte budget at runtime
(a coordinator's fair-share input). Per-generation bytes recorded in each
delta enable historyBytes() introspection without a storage size API.
Tests: per-write generation resolution, pin freeze vs add/update/remove,
drop-without-restore corruption-trap (fault injector), clean-reopen replay,
maxBytes/maxAge/no-cap reclamation, retention-then-reopen. 107 db/generation/
temporal tests green, tsc clean. Docs (ADR-001, consistency-model, snapshots
guide, api reference, RELEASES) updated to per-write granularity + retention.
2026-06-22 15:19:58 -07:00
|
|
|
|
await brain.compactHistory({ maxGenerations: 2 }) // raise the horizon
|
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
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|