brainy/tests/integration/transact-forward-ref-graph.test.ts
David Snelling a175406497 fix: transact forward references resolve graph endpoint ints at execute time
transact() promises atomic forward references — add an entity and relate to
it in one batch — but the planner resolved relationship endpoint ints at
PLAN time, before the batch's add operations had applied. Asking the id
mapper about an entity that does not exist yet made the (correctly strict)
native mapper throw in EntityIdMapper.getOrAssign, so
transact([{op:'add', id:X}, {op:'relate', to:X}]) failed on native
deployments; the permissive JS mapper masked the bug and silently leaked an
id assignment whenever a batch was later rejected by a commit precondition
(normal control flow since the conditional-commit CAS landed).

Make endpoint resolution lazy: AddToGraphIndexOperation and
RemoveFromGraphIndexOperation take VerbEndpointInts — an eager
{sourceInt, targetInt} or a thunk evaluated when the operation EXECUTES,
mirroring the constructor's already-lazy generationFn. The four
transact-planner sites (relate, its bidirectional reverse edge, remove's
relationship cascade, unrelate) pass thunks, so resolution happens inside
the commit after same-batch adds have applied; the seven single-operation
sites keep eager objects (their endpoints provably pre-exist — relate
validates both, unrelate/updateRelation/remove read the existing verb).
The remove operation's rollback captures the ints resolved at execute so
its re-add uses the same mappings.

Byproduct fix: a rejected batch no longer pollutes the id mapper — the
regression suite pins this (mapper has no assignment for the phantom
entity after a precondition-rejected forward-ref batch), alongside the
exact reported shape, both-endpoints-in-batch, bidirectional,
add+relate+remove in one batch, and the split-transact control
(tests/integration/transact-forward-ref-graph.test.ts).
2026-07-10 18:06:37 -07:00

140 lines
6 KiB
TypeScript

/**
* @module tests/integration/transact-forward-ref-graph
* @description Regression for a consumer-reported native/JS parity bug:
* `transact([{op:'add', id:X}, {op:'relate', to:X}])` — the platform's first
* atomic add+relate consumer — threw in `EntityIdMapper.getOrAssign` at PLAN
* time on the native id mapper (an entity added in the same batch does not
* exist when the planner runs, so a strict mapper rightly refuses to assign;
* the permissive JS mapper masked the bug — and silently leaked an int
* assignment whenever the batch was later rejected at precommit).
*
* Fix: graph-index operations resolve endpoint ints at EXECUTE time (a lazy
* thunk, mirroring the existing lazy `generationFn`), after the batch's add
* operations have applied. These tests pin every forward-ref shape on the JS
* path, including the JS-observable deferral proof: a REJECTED batch leaves
* the id mapper untouched (pre-fix, plan-time resolution assigned ints for
* entities that never came to exist). The native side is locked in by the
* accelerator's own gate running this same shape.
*/
import { describe, it, expect, beforeEach, 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 { NounType, VerbType } from '../../src/types/graphTypes.js'
let seq = 0
const freshId = (): string =>
`00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}`
describe('transact() forward references into the graph index (native/JS parity)', () => {
let dir: string
let brain: any
beforeEach(async () => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-fwd-ref-'))
brain = new Brainy({
requireSubtype: false,
storage: { type: 'filesystem', path: dir },
dimensions: 384,
silent: true
})
await brain.init()
})
afterEach(async () => {
await brain.close()
fs.rmSync(dir, { recursive: true, force: true })
})
it('CASE 1 (the exact repro): add + relate-to-that-add in ONE transact', async () => {
const agent = await brain.add({ id: freshId(), data: 'agent probe', type: NounType.Person })
const thread = freshId()
const db = await brain.transact([
{ op: 'add', id: thread, data: 'thread PROBE-ONE', type: NounType.Thing },
{ op: 'relate', from: agent, to: thread, type: VerbType.RelatedTo }
])
// The batch committed as one generation and the edge traverses.
expect(db.generation).toBeGreaterThan(0)
const related = await brain.related(agent)
expect(related.map((r: any) => r.to)).toContain(thread)
})
it('both endpoints created in-batch: add A + add B + relate A→B', async () => {
const a = freshId()
const b = freshId()
await brain.transact([
{ op: 'add', id: a, data: 'node A', type: NounType.Thing },
{ op: 'add', id: b, data: 'node B', type: NounType.Thing },
{ op: 'relate', from: a, to: b, type: VerbType.Contains }
])
expect((await brain.related(a)).map((r: any) => r.to)).toContain(b)
})
it('bidirectional relate to an in-batch add: both edges traverse', async () => {
const hub = await brain.add({ id: freshId(), data: 'hub', type: NounType.Thing })
const spoke = freshId()
await brain.transact([
{ op: 'add', id: spoke, data: 'spoke', type: NounType.Thing },
{ op: 'relate', from: hub, to: spoke, type: VerbType.RelatedTo, bidirectional: true }
])
expect((await brain.related(hub)).map((r: any) => r.to)).toContain(spoke)
expect((await brain.related(spoke)).map((r: any) => r.to)).toContain(hub)
})
it('add + relate + remove in ONE transact: the cascade covers the in-batch verb', async () => {
const keeper = await brain.add({ id: freshId(), data: 'keeper', type: NounType.Thing })
const doomed = freshId()
await brain.transact([
{ op: 'add', id: doomed, data: 'doomed', type: NounType.Thing },
{ op: 'relate', from: keeper, to: doomed, type: VerbType.Contains },
{ op: 'remove', id: doomed }
])
expect(await brain.get(doomed)).toBeNull()
expect((await brain.related(keeper)).length).toBe(0)
})
it('CASE 2 control: the same two ops split across two transacts still work', async () => {
const agent = await brain.add({ id: freshId(), data: 'agent two', type: NounType.Person })
const thread = freshId()
await brain.transact([{ op: 'add', id: thread, data: 'thread two', type: NounType.Thing }])
await brain.transact([{ op: 'relate', from: agent, to: thread, type: VerbType.RelatedTo }])
expect((await brain.related(agent)).map((r: any) => r.to)).toContain(thread)
})
it('a REJECTED forward-ref batch leaves the id mapper untouched (the deferral proof)', async () => {
const existing = await brain.add({
id: freshId(),
data: 'cas anchor',
type: NounType.Thing
})
const never = freshId()
// Reject the batch via a stale per-op CAS — planning completes, the
// commit precondition throws, nothing applies.
await expect(
brain.transact([
{ op: 'add', id: never, data: 'never exists', type: NounType.Thing },
{ op: 'relate', from: existing, to: never, type: VerbType.RelatedTo },
{ op: 'update', id: existing, metadata: { poke: 1 }, ifRev: 999 }
])
).rejects.toMatchObject({ name: 'RevisionConflictError' })
// Nothing applied…
expect(await brain.get(never)).toBeNull()
// …and the id mapper was never asked to assign for the phantom entity —
// pre-fix, plan-time resolution leaked an int here on every rejected batch.
const idMapper = brain['metadataIndex'].getIdMapper()
expect(idMapper.getInt(never)).toBeUndefined()
// The same shape then succeeds cleanly on retry.
await brain.transact([
{ op: 'add', id: never, data: 'exists now', type: NounType.Thing },
{ op: 'relate', from: existing, to: never, type: VerbType.RelatedTo }
])
expect((await brain.related(existing)).map((r: any) => r.to)).toContain(never)
})
})