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

@ -283,6 +283,17 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* `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<T>
/**
@ -2188,7 +2199,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// 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<T = any> implements BrainyInterface<T> {
tx.addOperation(
new AddToGraphIndexOperation(
this.graphIndex, verb, sourceInt, targetInt,
this.graphWriteGeneration,
(verbInt) => this.cacheVerbInt(verbInt, id)
)
)
@ -2618,6 +2630,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
tx.addOperation(
new AddToGraphIndexOperation(
this.graphIndex, reverseVerb, targetInt, sourceInt,
this.graphWriteGeneration,
(verbInt) => this.cacheVerbInt(verbInt, reverseId)
)
)
@ -2660,7 +2673,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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))

View file

@ -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<bigint> {
async addVerb(
verb: GraphVerb,
sourceInt: bigint,
targetInt: bigint,
generation: bigint
): Promise<bigint> {
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<void> {
async removeVerb(verbId: string, generation: bigint): Promise<void> {
void generation // Contract parity — no per-generation chain in the JS index.
await this.ensureInitialized()
// Load verb from cache/storage to get type info

View file

@ -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<bigint>
addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint, generation: bigint): Promise<bigint>
/**
* @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<void>
removeVerb(verbId: string, generation: bigint): Promise<void>
rebuild(): Promise<void>
flush(): Promise<void>

View file

@ -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<RollbackAction> {
// 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<RollbackAction> {
// 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)
}
}
}