141 lines
6 KiB
TypeScript
141 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)
|
||
|
|
})
|
||
|
|
})
|