From 0951fa1da038c0fbc468c2afad97894c9d402ace Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 16 Jun 2026 09:41:59 -0700 Subject: [PATCH] feat(8.0): thread commit generation through the graph-write provider contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/brainy.ts | 30 +++-- src/graph/graphAdjacencyIndex.ts | 19 ++- src/plugin.ts | 14 ++- src/transaction/operations/IndexOperations.ts | 35 ++++-- tests/unit/graph/bigint-contract.test.ts | 12 +- .../graphIndexOperations-generation.test.ts | 111 ++++++++++++++++++ 6 files changed, 195 insertions(+), 26 deletions(-) create mode 100644 tests/unit/transaction/graphIndexOperations-generation.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index f98f3e2c..6b756002 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -283,6 +283,17 @@ export class Brainy implements BrainyInterface { * `compactHistory()` and the `Db` value type. */ private generationStore!: GenerationStore + /** + * Resolves the in-flight write generation as a BigInt, for stamping graph + * edges via the {@link GraphIndexProvider} contract. Passed to every + * `AddToGraphIndexOperation`/`RemoveFromGraphIndexOperation` and evaluated + * when the operation executes — inside a `transact()` batch the generation + * store has assigned the batch generation by then; for single-op writes it + * reads the post-write watermark. The arrow body reads `generationStore` + * lazily, so it is safe to define before `init()` assigns the store. + */ + private readonly graphWriteGeneration = (): bigint => + BigInt(this.generationStore.generation()) /** Lazily built host surface shared by every `Db` value of this brain. */ private _dbHost?: DbHost /** @@ -2188,7 +2199,7 @@ export class Brainy implements BrainyInterface { // rollback can re-add through the BigInt addVerb contract) const { sourceInt, targetInt } = this.resolveVerbEndpointInts(verb) tx.addOperation( - new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt) + new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt, this.graphWriteGeneration) ) // Delete verb metadata tx.addOperation( @@ -2580,6 +2591,7 @@ export class Brainy implements BrainyInterface { tx.addOperation( new AddToGraphIndexOperation( this.graphIndex, verb, sourceInt, targetInt, + this.graphWriteGeneration, (verbInt) => this.cacheVerbInt(verbInt, id) ) ) @@ -2618,6 +2630,7 @@ export class Brainy implements BrainyInterface { tx.addOperation( new AddToGraphIndexOperation( this.graphIndex, reverseVerb, targetInt, sourceInt, + this.graphWriteGeneration, (verbInt) => this.cacheVerbInt(verbInt, reverseId) ) ) @@ -2660,7 +2673,7 @@ export class Brainy implements BrainyInterface { if (verb && endpointInts) { tx.addOperation( new RemoveFromGraphIndexOperation( - this.graphIndex, verb, endpointInts.sourceInt, endpointInts.targetInt + this.graphIndex, verb, endpointInts.sourceInt, endpointInts.targetInt, this.graphWriteGeneration ) ) } @@ -2785,12 +2798,13 @@ export class Brainy implements BrainyInterface { if (typeChanged && reindexInts) { tx.addOperation( new RemoveFromGraphIndexOperation( - this.graphIndex, existing, reindexInts.sourceInt, reindexInts.targetInt + this.graphIndex, existing, reindexInts.sourceInt, reindexInts.targetInt, this.graphWriteGeneration ) ) tx.addOperation( new AddToGraphIndexOperation( this.graphIndex, verbForIndex, reindexInts.sourceInt, reindexInts.targetInt, + this.graphWriteGeneration, (verbInt) => this.cacheVerbInt(verbInt, params.id) ) ) @@ -4391,7 +4405,7 @@ export class Brainy implements BrainyInterface { for (const verb of allVerbs) { const { sourceInt, targetInt } = this.resolveVerbEndpointInts(verb) tx.addOperation( - new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt) + new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt, this.graphWriteGeneration) ) tx.addOperation( new DeleteVerbMetadataOperation(this.storage, verb.id) @@ -5977,7 +5991,7 @@ export class Brainy implements BrainyInterface { for (const verb of cascade.values()) { const { sourceInt, targetInt } = this.resolveVerbEndpointInts(verb) plan.operations.push( - new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt), + new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt, this.graphWriteGeneration), new DeleteVerbMetadataOperation(this.storage, verb.id) ) plan.touchedVerbs.push(verb.id) @@ -6103,7 +6117,7 @@ export class Brainy implements BrainyInterface { targetId: params.to }), new SaveVerbMetadataOperation(this.storage, id, verbMetadata), - new AddToGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt, (verbInt) => + new AddToGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt, this.graphWriteGeneration, (verbInt) => this.cacheVerbInt(verbInt, id) ) ) @@ -6131,7 +6145,7 @@ export class Brainy implements BrainyInterface { targetId: params.from }), new SaveVerbMetadataOperation(this.storage, reverseId, verbMetadata), - new AddToGraphIndexOperation(this.graphIndex, reverseVerb, targetInt, sourceInt, (verbInt) => + new AddToGraphIndexOperation(this.graphIndex, reverseVerb, targetInt, sourceInt, this.graphWriteGeneration, (verbInt) => this.cacheVerbInt(verbInt, reverseId) ) ) @@ -6161,7 +6175,7 @@ export class Brainy implements BrainyInterface { if (verb) { const { sourceInt, targetInt } = this.resolveVerbEndpointInts(verb) plan.operations.push( - new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt) + new RemoveFromGraphIndexOperation(this.graphIndex, verb, sourceInt, targetInt, this.graphWriteGeneration) ) } plan.operations.push(new DeleteVerbMetadataOperation(this.storage, id)) diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts index 22dfadfd..eaf948c5 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -646,9 +646,20 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { * @param verb - The verb to index (endpoint UUIDs are authoritative). * @param sourceInt - The source entity's interned int (contract parity). * @param targetInt - The target entity's interned int (contract parity). + * @param generation - The commit generation (contract parity). The JS index + * keeps a single live adjacency view, not a per-generation edge chain, so + * it ignores this: `db.asOf(g)` graph hops on the open-core path see edges + * as-of-now (the one documented graph time-travel limitation — native + * providers thread this into a versioned endpoint store for correctness). * @returns The interned verb int for `verb.id` (stable for the index lifetime). */ - async addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint): Promise { + async addVerb( + verb: GraphVerb, + sourceInt: bigint, + targetInt: bigint, + generation: bigint + ): Promise { + void generation // Contract parity — no per-generation chain in the JS index. await this.ensureInitialized() return BigInt(await this.indexVerb(verb)) } @@ -703,9 +714,13 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { * retained so previously returned verb ints stay resolvable via * {@link verbIntsToIds}. * @param verbId - The verb's UUID string. + * @param generation - The commit generation (contract parity). The JS index + * tombstones immediately rather than chaining the removal per generation, + * so it ignores this (see {@link addVerb} for the time-travel rationale). * @returns Resolves once the verb no longer appears in reads. */ - async removeVerb(verbId: string): Promise { + async removeVerb(verbId: string, generation: bigint): Promise { + void generation // Contract parity — no per-generation chain in the JS index. await this.ensureInitialized() // Load verb from cache/storage to get type info diff --git a/src/plugin.ts b/src/plugin.ts index 66e89a06..52289040 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -231,16 +231,26 @@ export interface GraphIndexProvider { * @param verb - The verb to index (endpoint UUIDs + derived `sourceInt`/`targetInt`). * @param sourceInt - The source entity's interned int. * @param targetInt - The target entity's interned int. + * @param generation - Brainy's commit generation for this write — the same + * watermark the storage layer stamps onto the record. A provider with a + * per-generation edge chain records the edge's existence at this generation + * so `db.asOf(g)` graph hops resolve historically correct endpoints; the + * JS baseline has no such chain and ignores it (graph time-travel is a + * native-provider capability — see the consistency-model doc). * @returns The interned verb int for `verb.id` (feeds Brainy's warm cache). */ - addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint): Promise + addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint, generation: bigint): Promise /** * @description Remove one verb from the index by its id string. The verb's * interned int stays reserved (ints are never recycled within a generation). * @param verbId - The verb's UUID string. + * @param generation - Brainy's commit generation for this removal. A provider + * with a per-generation edge chain tombstones the edge at this generation + * (so it remains visible to `db.asOf(g)` for `g` before the removal); the + * JS baseline removes immediately and ignores it. * @returns Resolves once the verb no longer appears in reads. */ - removeVerb(verbId: string): Promise + removeVerb(verbId: string, generation: bigint): Promise rebuild(): Promise flush(): Promise diff --git a/src/transaction/operations/IndexOperations.ts b/src/transaction/operations/IndexOperations.ts index 4082206a..f0bede05 100644 --- a/src/transaction/operations/IndexOperations.ts +++ b/src/transaction/operations/IndexOperations.ts @@ -169,6 +169,12 @@ export class RemoveFromMetadataIndexOperation implements Operation { * shared idMapper (`getOrAssign`) and passes them alongside the verb; the * provider returns the interned verb int, which is surfaced through the * optional `onVerbInt` callback so the coordinator can feed its warm cache. + * + * Generation: `generationFn` is resolved at execute time (not construction) so + * the edge is stamped at the transaction's in-flight commit generation — which + * the generation store only assigns once the batch begins executing. The same + * generation is reused for the rollback removal, so an add and its undo + * reference one watermark in a provider's per-generation edge chain. */ export class AddToGraphIndexOperation implements Operation { readonly name = 'AddToGraphIndex' @@ -178,6 +184,8 @@ export class AddToGraphIndexOperation implements Operation { * @param verb - The verb to index (`sourceInt`/`targetInt` mirrored on it). * @param sourceInt - The source entity's interned int. * @param targetInt - The target entity's interned int. + * @param generationFn - Resolves the commit generation to stamp this edge at, + * evaluated when the operation executes (see class note). * @param onVerbInt - Optional hook invoked with the interned verb int * returned by the provider (feeds the coordinator's verb-int warm cache). */ @@ -186,18 +194,21 @@ export class AddToGraphIndexOperation implements Operation { private readonly verb: GraphVerb, private readonly sourceInt: bigint, private readonly targetInt: bigint, + private readonly generationFn: () => bigint, private readonly onVerbInt?: (verbInt: bigint) => void ) {} async execute(): Promise { - // Add verb to graph index - const verbInt = await this.index.addVerb(this.verb, this.sourceInt, this.targetInt) + // Stamp this edge at the in-flight commit generation; reuse it for the + // rollback so add + undo reference the same watermark. + const generation = this.generationFn() + const verbInt = await this.index.addVerb(this.verb, this.sourceInt, this.targetInt, generation) this.onVerbInt?.(verbInt) // Return rollback action return async () => { // Remove verb from graph index - await this.index.removeVerb(this.verb.id) + await this.index.removeVerb(this.verb.id, generation) } } } @@ -209,8 +220,10 @@ export class AddToGraphIndexOperation implements Operation { * - Re-add verb to graph index * * 8.0 u64 contract: rollback re-adds through `addVerb(verb, sourceInt, - * targetInt)`, so the coordinator resolves the endpoint ints up front - * (while the entity → int mappings are guaranteed to still exist). + * targetInt, generation)`, so the coordinator resolves the endpoint ints up + * front (while the entity → int mappings are guaranteed to still exist). The + * removal generation is resolved at execute time and reused for the rollback + * re-add, so the round trip references one watermark. */ export class RemoveFromGraphIndexOperation implements Operation { readonly name = 'RemoveFromGraphIndex' @@ -220,22 +233,26 @@ export class RemoveFromGraphIndexOperation implements Operation { * @param verb - The verb being removed (required for rollback re-add). * @param sourceInt - The source entity's interned int (rollback re-add). * @param targetInt - The target entity's interned int (rollback re-add). + * @param generationFn - Resolves the commit generation for this removal, + * evaluated when the operation executes. */ constructor( private readonly index: GraphIndexProvider, private readonly verb: GraphVerb, // Required for rollback private readonly sourceInt: bigint, - private readonly targetInt: bigint + private readonly targetInt: bigint, + private readonly generationFn: () => bigint ) {} async execute(): Promise { - // Remove verb from graph index - await this.index.removeVerb(this.verb.id) + // Resolve the removal generation once; reuse it for the rollback re-add. + const generation = this.generationFn() + await this.index.removeVerb(this.verb.id, generation) // Return rollback action return async () => { // Re-add verb with original data - await this.index.addVerb(this.verb, this.sourceInt, this.targetInt) + await this.index.addVerb(this.verb, this.sourceInt, this.targetInt, generation) } } } diff --git a/tests/unit/graph/bigint-contract.test.ts b/tests/unit/graph/bigint-contract.test.ts index 44d5a76b..e1c52b2d 100644 --- a/tests/unit/graph/bigint-contract.test.ts +++ b/tests/unit/graph/bigint-contract.test.ts @@ -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([]) diff --git a/tests/unit/transaction/graphIndexOperations-generation.test.ts b/tests/unit/transaction/graphIndexOperations-generation.test.ts new file mode 100644 index 00000000..46e7dde2 --- /dev/null +++ b/tests/unit/transaction/graphIndexOperations-generation.test.ts @@ -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 { + calls.push({ method: 'addVerb', generation, verbId: verb.id }) + return 99n // interned verb int + }, + async removeVerb(verbId: string, generation: bigint): Promise { + 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) + }) +})