feat(8.0): thread commit generation through the graph-write provider contract

Graph time-travel needs an edge's existence recorded per generation so
db.asOf(g) hops resolve historically correct endpoints. The metadata layer
already threads brainy's commit generation per write; the graph write path did
not, leaving a versioned verb-endpoint store unable to answer "which edges
existed at generation g".

- GraphIndexProvider.addVerb/removeVerb gain a `generation: bigint` parameter
  (the same watermark the storage layer stamps onto the record). A provider with
  a per-generation edge chain stamps the edge at that generation; the JS baseline
  has no such chain and accepts-and-ignores it — graph time-travel is a
  native-provider capability, and the open-core path serves edges as-of-now (the
  one documented graph time-travel limitation).
- The two graph transaction operations resolve the generation via a thunk at
  EXECUTE time: the generation store assigns the batch generation only once the
  commit begins executing, after the operations are planned. The same generation
  is reused for an operation's rollback half.
- All graph-write call sites pass the in-flight generation.

Adds a spy-provider test proving the threading, execute-time resolution, and
forward/rollback generation reuse. The JS index ignores the value, so behaviour
is unchanged: unit 1402/1402, db-mvcc 25/25, bigint-contract relate/unrelate 10/10.
This commit is contained in:
David Snelling 2026-06-16 09:41:59 -07:00
parent b26d3d42b3
commit 0951fa1da0
6 changed files with 195 additions and 26 deletions

View file

@ -64,11 +64,13 @@ describe('GraphAdjacencyIndex — BigInt boundary (JS implementation)', () => {
verb.sourceInt = sourceInt
verb.targetInt = targetInt
const verbInt = await index.addVerb(verb, sourceInt, targetInt)
// The 4th arg is the commit generation — contract parity with native
// providers; the JS index ignores it (no per-generation edge chain).
const verbInt = await index.addVerb(verb, sourceInt, targetInt, 1n)
expect(typeof verbInt).toBe('bigint')
// Re-adding the same verb id returns the SAME interned int (stable).
const verbIntAgain = await index.addVerb(verb, sourceInt, targetInt)
const verbIntAgain = await index.addVerb(verb, sourceInt, targetInt, 1n)
expect(verbIntAgain).toBe(verbInt)
// Both directed verb-int reads surface the int from addVerb.
@ -88,7 +90,7 @@ describe('GraphAdjacencyIndex — BigInt boundary (JS implementation)', () => {
const bInt = BigInt(idMapper.getOrAssign('uuid-b'))
const verb = makeVerb(VERB_UUID_2, 'uuid-a', 'uuid-b')
await index.addVerb(verb, aInt, bInt)
await index.addVerb(verb, aInt, bInt, 1n)
const out = await index.getNeighbors(aInt, { direction: 'out' })
expect(out).toEqual([bInt])
@ -113,7 +115,7 @@ describe('GraphAdjacencyIndex — BigInt boundary (JS implementation)', () => {
const tInt = BigInt(idMapper.getOrAssign('uuid-t'))
const verb = makeVerb(VERB_UUID_3, 'uuid-s', 'uuid-t')
const verbInt = await index.addVerb(verb, sInt, tInt)
const verbInt = await index.addVerb(verb, sInt, tInt, 1n)
// Persist the verb (vector + metadata — getVerb requires both) so
// removeVerb can resolve its type for count updates.
await storage.saveVerb({
@ -129,7 +131,7 @@ describe('GraphAdjacencyIndex — BigInt boundary (JS implementation)', () => {
createdAt: Date.now()
})
await index.removeVerb(verb.id)
await index.removeVerb(verb.id, 2n)
// Removed verb no longer appears in reads…
expect(await index.getVerbIdsBySource(sInt)).toEqual([])

View file

@ -0,0 +1,111 @@
/**
* Generation threading through the graph-index transaction operations.
*
* The graph time-travel contract (AddVerb/RemoveVerb gain a `generation`) is
* load-bearing for native providers: `db.asOf(g)` graph hops resolve historical
* endpoints from a per-generation edge chain the provider stamps at write time.
* Brainy's JS index ignores the value, so a behavioural test against it proves
* nothing these tests use a spy provider to assert the operation layer:
* 1. passes the resolved generation to addVerb/removeVerb,
* 2. resolves the generation at EXECUTE time, not construction (the batch
* generation is only assigned once the commit begins executing), and
* 3. reuses one generation across an operation's forward + rollback halves
* (so an add and its undo reference the same watermark in the chain).
*/
import { describe, it, expect } from 'vitest'
import {
AddToGraphIndexOperation,
RemoveFromGraphIndexOperation
} from '../../../src/transaction/operations/IndexOperations.js'
import type { GraphIndexProvider } from '../../../src/plugin.js'
import type { GraphVerb } from '../../../src/coreTypes.js'
import { VerbType } from '../../../src/types/graphTypes.js'
/** A graph verb with just the fields the operations touch. */
function makeVerb(id: string): GraphVerb {
return {
id,
sourceId: 'src-uuid',
targetId: 'tgt-uuid',
type: VerbType.RelatedTo,
sourceInt: 10n,
targetInt: 20n
} as unknown as GraphVerb
}
/** Records every (method, generation) the operations push to the provider. */
function makeSpyProvider(): {
provider: GraphIndexProvider
calls: Array<{ method: 'addVerb' | 'removeVerb'; generation: bigint; verbId: string }>
} {
const calls: Array<{ method: 'addVerb' | 'removeVerb'; generation: bigint; verbId: string }> = []
const provider = {
async addVerb(verb: GraphVerb, _s: bigint, _t: bigint, generation: bigint): Promise<bigint> {
calls.push({ method: 'addVerb', generation, verbId: verb.id })
return 99n // interned verb int
},
async removeVerb(verbId: string, generation: bigint): Promise<void> {
calls.push({ method: 'removeVerb', generation, verbId })
}
} as unknown as GraphIndexProvider
return { provider, calls }
}
describe('Graph index operations — generation threading', () => {
it('AddToGraphIndexOperation stamps addVerb (and its rollback removeVerb) at the resolved generation', async () => {
const { provider, calls } = makeSpyProvider()
const verb = makeVerb('verb-1')
const op = new AddToGraphIndexOperation(provider, verb, 10n, 20n, () => 7n)
const rollback = await op.execute()
expect(calls).toEqual([{ method: 'addVerb', generation: 7n, verbId: 'verb-1' }])
await rollback()
expect(calls[1]).toEqual({ method: 'removeVerb', generation: 7n, verbId: 'verb-1' })
})
it('RemoveFromGraphIndexOperation stamps removeVerb (and its rollback addVerb) at the resolved generation', async () => {
const { provider, calls } = makeSpyProvider()
const verb = makeVerb('verb-2')
const op = new RemoveFromGraphIndexOperation(provider, verb, 10n, 20n, () => 12n)
const rollback = await op.execute()
expect(calls).toEqual([{ method: 'removeVerb', generation: 12n, verbId: 'verb-2' }])
await rollback()
expect(calls[1]).toEqual({ method: 'addVerb', generation: 12n, verbId: 'verb-2' })
})
it('resolves the generation at EXECUTE time, not at construction', async () => {
// The generation store assigns the batch generation only once the commit
// begins executing — after the operations are built. The thunk must be read
// then, so a value that changes between construction and execute is observed
// at its execute-time value.
const { provider, calls } = makeSpyProvider()
let current = 1n
const op = new AddToGraphIndexOperation(provider, makeVerb('verb-3'), 10n, 20n, () => current)
current = 42n // store assigns the batch generation after the op is built
await op.execute()
expect(calls[0].generation).toBe(42n)
})
it('still surfaces the provider verb int through onVerbInt after the signature change', async () => {
const { provider } = makeSpyProvider()
let seen: bigint | undefined
const op = new AddToGraphIndexOperation(
provider,
makeVerb('verb-4'),
10n,
20n,
() => 5n,
(verbInt) => {
seen = verbInt
}
)
await op.execute()
expect(seen).toBe(99n)
})
})