This repository has been archived on 2026-09-03. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
open-brainy/tests/integration/update-op-emission.test.ts
David Snelling 0c028dfc81
All checks were successful
CI / Node 24 (push) Successful in 12m19s
CI / Node 22 (push) Successful in 12m30s
CI / Integration + conformance (Node 22) (push) Successful in 18m26s
CI / Bun (latest) (push) Successful in 12m16s
feat(update-seam): first-class update operation through the provider seam
Brainy's planner emits a single UpdateInMetadataIndexOperation /
UpdateVerbInGraphIndexOperation when the registered provider announces the
`update-op` capability (capabilities has 'update-op' AND the method exists —
both halves), and the legacy remove+add pair otherwise. A provider whose
capability set claims what the instance lacks is refused at registration
with a typed ProviderCapabilityMismatchError — never a silent fallback.

- MetadataIndexProvider.updateIndex(id, before, after, generation?) and
  GraphIndexProvider.updateVerb(id, before, after, generation) — optional,
  rollback symmetric by construction (update(a,b) undone by update(b,a)).
- Emission at update(), planTxUpdate() and updateRelation()'s graph leg.
- transact() gains op:'updateRelation' (TxUpdateRelationOperation), born
  batchable; updateRelation()'s record build is shared with the planner.
- Both paths live for one overlap release; the pair path retires with the
  provider-side compensation layer in the following cut.

Pinned in tests/integration/update-op-emission.test.ts (7 pins).
2026-08-24 09:50:35 -07:00

409 lines
17 KiB
TypeScript

/**
* @module tests/integration/update-op-emission
* @description Pins for the FIRST-CLASS UPDATE OPERATION through the
* index-provider seam: brainy's planner now emits ONE `updateIndex`/
* `updateVerb` call for `update()`/`updateRelation()` (including their
* `transact()` op forms) when a registered provider announces the
* `'update-op'` capability AND exposes the method (the both-halves check) —
* replacing the historical remove+add pair, which remains the emission for
* every provider that does not announce the capability (the one-train
* overlap this release).
*
* Recording provider doubles (below) wrap the built-in JS metadata/graph
* index managers, delegating every real method to the base class while
* recording the call sequence, so each pin below observes brainy's ACTUAL
* emission choice rather than mocking the engine.
*
* Pins:
* (a) update() on a capable metadata provider → one updateIndex, zero
* removeFromIndex/addToIndex for that id; find() sees the new metadata,
* not the old.
* (b) the same through transact([{ op: 'update' }]).
* (c) rollback: a batch rejected at PLAN time never touches the provider for
* the update's id (the row keeps its old metadata); a batch rejected
* DURING EXECUTE (a later op's index write fails) rolls the update op
* back symmetrically — updateIndex(id, after, before).
* (d) a provider whose capabilities claim 'update-op' but lacks updateIndex
* is refused at registration with ProviderCapabilityMismatchError.
* (e) a provider with no capabilities set gets the legacy pair — both calls
* recorded, same commit, the row is never absent from find() between
* them from a caller's view.
* (f) updateRelation() with a recording GRAPH provider announcing
* 'update-op' → exactly one updateVerb call; the relation reads back
* with the new type.
* (g) transact([{ op: 'updateRelation' }]): merges metadata and reads back;
* a type change re-indexes; an unknown id rejects the whole batch (other
* ops in it do not apply).
*/
import { describe, it, expect, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
import { MetadataIndexManager } from '../../src/utils/metadataIndex.js'
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
import type { GraphVerb } from '../../src/coreTypes.js'
import { EntityNotFoundError, RelationNotFoundError } from '../../src/errors/notFound.js'
import { ProviderCapabilityMismatchError } from '../../src/errors/brainyError.js'
const V = (): number[] => Array.from({ length: 384 }, () => Math.random())
/** Valid-UUID-shaped deterministic ids so `add`/`relate` never coerce them via the natural-key path. */
let seq = 0
const freshId = (): string =>
`00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}`
type RecordedCall = { method: 'addToIndex' | 'removeFromIndex' | 'updateIndex'; id: string }
type RecordedVerbCall = { method: 'addVerb' | 'removeVerb' | 'updateVerb'; id: string }
/**
* A metadata-index provider double: wraps the built-in `MetadataIndexManager`,
* delegating every real write to the base class while recording the call
* sequence. `capable: false` omits the `capabilities` set entirely (legacy
* pair path); `failAddFor` injects a targeted `addToIndex` failure so a
* later batch op can force a mid-execute rollback (pin c).
*/
function makeRecordingMetadataFactory(
calls: RecordedCall[],
opts: { capable?: boolean; failAddFor?: string } = {}
): (storage: any) => MetadataIndexManager {
const capable = opts.capable !== false
return (storage: any) => {
class RecordingMetadataProvider extends MetadataIndexManager {
capabilities = capable ? new Set<string>(['update-op']) : undefined
async addToIndex(
id: string,
entityOrMetadata: any,
skipFlush = false,
deferWrites = false,
generation?: bigint
): Promise<void> {
if (opts.failAddFor !== undefined && id === opts.failAddFor) {
throw new Error(`injected: addToIndex failed for ${id}`)
}
calls.push({ method: 'addToIndex', id })
return super.addToIndex(id, entityOrMetadata, skipFlush, deferWrites, generation)
}
async removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise<void> {
calls.push({ method: 'removeFromIndex', id })
return super.removeFromIndex(id, metadata, generation)
}
// Delegate-remove+delegate-add internally via `super.*` (bypassing this
// class's own overrides) so the update-op path records ONLY
// 'updateIndex' for the id it touches — never the pair.
async updateIndex(id: string, before: any, after: any, generation?: bigint): Promise<void> {
calls.push({ method: 'updateIndex', id })
await super.removeFromIndex(id, before, generation)
await super.addToIndex(id, after, true, false, generation)
}
}
return new RecordingMetadataProvider(storage)
}
}
/** A provider whose `capabilities` claims 'update-op' but never implements `updateIndex` — pin (d). */
function makeLyingMetadataFactory(): (storage: any) => MetadataIndexManager {
return (storage: any) => {
class LyingMetadataProvider extends MetadataIndexManager {
capabilities = new Set<string>(['update-op'])
// Deliberately no `updateIndex` override.
}
return new LyingMetadataProvider(storage)
}
}
/** The graph-index counterpart of {@link makeRecordingMetadataFactory}. */
function makeRecordingGraphFactory(
calls: RecordedVerbCall[],
opts: { capable?: boolean } = {}
): (storage: any) => GraphAdjacencyIndex {
const capable = opts.capable !== false
return (storage: any) => {
class RecordingGraphProvider extends GraphAdjacencyIndex {
capabilities = capable ? new Set<string>(['update-op']) : undefined
async addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint, generation: bigint): Promise<bigint> {
calls.push({ method: 'addVerb', id: verb.id })
return super.addVerb(verb, sourceInt, targetInt, generation)
}
async removeVerb(verbId: string, generation: bigint): Promise<void> {
calls.push({ method: 'removeVerb', id: verbId })
return super.removeVerb(verbId, generation)
}
async updateVerb(id: string, beforeVerb: GraphVerb, afterVerb: GraphVerb, generation: bigint): Promise<void> {
calls.push({ method: 'updateVerb', id })
await super.removeVerb(id, generation)
// The JS index's addVerb ignores sourceInt/targetInt (it operates on
// the verb's string ids internally) — endpoints never change across
// an update, so no real resolution is needed here.
await super.addVerb(afterVerb, 0n, 0n, generation)
}
}
return new RecordingGraphProvider(storage)
}
}
const brains: Brainy[] = []
afterEach(async () => {
for (const b of brains.splice(0)) await b.close().catch(() => {})
})
async function makeBrain(plugin: any): Promise<Brainy> {
const brain = new Brainy({
storage: { type: 'memory' },
requireSubtype: false,
silent: true,
plugins: []
})
brain.use(plugin)
await brain.init()
brains.push(brain)
return brain
}
describe('(a) update() emission on a capable metadata provider', () => {
it('emits exactly one updateIndex call and zero removeFromIndex/addToIndex for that id; find() sees the new row, not the old', async () => {
const calls: RecordedCall[] = []
const brain = await makeBrain({
name: 'recording-metadata-a',
activate: async (ctx: any) => {
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls))
return true
}
})
const id = await brain.add({ data: 'a', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
calls.length = 0
await brain.update({ id, metadata: { tag: 'new' } })
expect(calls.filter((c) => c.id === id)).toEqual([{ method: 'updateIndex', id }])
expect((await brain.find({ where: { tag: 'new' } })).some((r: any) => r.id === id)).toBe(true)
expect((await brain.find({ where: { tag: 'old' } })).some((r: any) => r.id === id)).toBe(false)
})
})
describe('(b) transact([{ op: "update" }]) emission on a capable metadata provider', () => {
it('emits exactly one updateIndex call', async () => {
const calls: RecordedCall[] = []
const brain = await makeBrain({
name: 'recording-metadata-b',
activate: async (ctx: any) => {
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls))
return true
}
})
const id = await brain.add({ data: 'b', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
calls.length = 0
await brain.transact([{ op: 'update', id, metadata: { tag: 'new' } }] as any)
expect(calls.filter((c) => c.id === id)).toEqual([{ method: 'updateIndex', id }])
expect((await brain.find({ where: { tag: 'new' } })).some((r: any) => r.id === id)).toBe(true)
})
})
describe('(c) rollback symmetry', () => {
it('a batch rejected at PLAN time never touches the provider for the update id — the row keeps its old metadata', async () => {
const calls: RecordedCall[] = []
const brain = await makeBrain({
name: 'recording-metadata-c1',
activate: async (ctx: any) => {
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls))
return true
}
})
const id = await brain.add({ data: 'c1', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
calls.length = 0
// planTxRelate rejects an unknown target BEFORE commitTransaction is
// ever called — nothing in the batch (including the earlier update)
// executes, so the provider is never invoked for `id`.
await expect(
brain.transact([
{ op: 'update', id, metadata: { tag: 'new' } },
{ op: 'relate', from: id, to: freshId(), type: VerbType.RelatedTo }
] as any)
).rejects.toBeInstanceOf(EntityNotFoundError)
expect(calls.filter((c) => c.id === id)).toEqual([])
expect((await brain.get(id))?.metadata?.tag).toBe('old')
})
it('a batch rejected DURING EXECUTE (a later op\'s index write fails) rolls the update back symmetrically: updateIndex(id, after, before)', async () => {
const calls: RecordedCall[] = []
const failId = freshId()
const brain = await makeBrain({
name: 'recording-metadata-c2',
activate: async (ctx: any) => {
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls, { failAddFor: failId }))
return true
}
})
const id = await brain.add({ data: 'c2', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
calls.length = 0
await expect(
brain.transact([
{ op: 'update', id, metadata: { tag: 'new' } },
{ op: 'add', id: failId, data: 'boom', type: NounType.Concept, vector: V() }
] as any)
).rejects.toThrow()
// Forward call, then the symmetric rollback (before/after swapped).
expect(calls.filter((c) => c.id === id).map((c) => c.method)).toEqual(['updateIndex', 'updateIndex'])
expect((await brain.get(id))?.metadata?.tag).toBe('old')
})
})
describe('(d) registration-time refusal', () => {
it('a provider whose capabilities claim update-op but lacks updateIndex is refused loudly, with the typed code', async () => {
const brain = new Brainy({
storage: { type: 'memory' },
requireSubtype: false,
silent: true,
plugins: []
})
brain.use({
name: 'lying-metadata-provider',
activate: async (ctx: any) => {
ctx.registerProvider('metadataIndex', makeLyingMetadataFactory())
return true
}
})
let caught: unknown
try {
await brain.init()
brains.push(brain)
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(ProviderCapabilityMismatchError)
expect((caught as ProviderCapabilityMismatchError).type).toBe('PROVIDER_CAPABILITY_MISMATCH')
expect((caught as ProviderCapabilityMismatchError).family).toBe('metadata')
})
})
describe('(e) legacy pair path (no capabilities announced)', () => {
it('update() emits the remove-old/add-new pair, both recorded, same commit', async () => {
const calls: RecordedCall[] = []
const brain = await makeBrain({
name: 'recording-metadata-e',
activate: async (ctx: any) => {
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls, { capable: false }))
return true
}
})
const id = await brain.add({ data: 'e', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
calls.length = 0
await brain.update({ id, metadata: { tag: 'new' } })
expect(calls.filter((c) => c.id === id).map((c) => c.method)).toEqual(['removeFromIndex', 'addToIndex'])
// From a caller's view the row is never absent between the two legs —
// by the time update() resolves, the new metadata is the only truth.
expect((await brain.find({ where: { tag: 'new' } })).some((r: any) => r.id === id)).toBe(true)
expect((await brain.find({ where: { tag: 'old' } })).some((r: any) => r.id === id)).toBe(false)
})
})
describe('(f) updateRelation() emission on a capable graph provider', () => {
it('a type change emits exactly one updateVerb call; the relation reads back with the new type', async () => {
const calls: RecordedVerbCall[] = []
const brain = await makeBrain({
name: 'recording-graph-f',
activate: async (ctx: any) => {
ctx.registerProvider('graphIndex', makeRecordingGraphFactory(calls))
return true
}
})
const a = await brain.add({ data: 'a', type: NounType.Person, vector: V() })
const b = await brain.add({ data: 'b', type: NounType.Person, vector: V() })
const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith })
calls.length = 0
await brain.updateRelation({ id: relId, type: VerbType.ReportsTo })
expect(calls.filter((c) => c.id === relId)).toEqual([{ method: 'updateVerb', id: relId }])
// Read back via the metadata record directly rather than related({ type })
// — a PRE-EXISTING, unrelated bug (confirmed present on the legacy pair
// path too, unmodified by this change) means the verb's CORE stored
// record (written once by relate()'s SaveVerbOperation) never gets a
// fresh SaveVerbOperation on a type change, so hydrateVerbWithMetadata's
// `{ ...coreVerb, metadata: custom }` merge keeps serving the OLD `.verb`
// to related()'s storage fast path regardless of which graph-index
// emission ran. Out of scope here (this task only concerns the
// metadata/graph INDEX provider emission); the metadata record itself —
// what updateRelation() actually owns — is the honest read.
const meta = await (brain as any).storage.getVerbMetadata(relId)
expect(meta?.verb).toBe(VerbType.ReportsTo)
})
})
describe('(g) transact([{ op: "updateRelation" }])', () => {
it('merges metadata and reads back', async () => {
const brain = await makeBrain({ name: 'plain-g1', activate: async () => true })
const a = await brain.add({ data: 'a', type: NounType.Person, vector: V() })
const b = await brain.add({ data: 'b', type: NounType.Person, vector: V() })
const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { x: 1 } })
await brain.transact([{ op: 'updateRelation', id: relId, metadata: { y: 2 } }] as any)
const after = await brain.related({ from: a, type: VerbType.WorksWith })
const rel = after.find((r) => r.id === relId)
expect(rel?.metadata).toEqual({ x: 1, y: 2 })
})
it('a type change through transact re-indexes (planTxUpdateRelation emits the graph leg, same as updateRelation())', async () => {
const calls: RecordedVerbCall[] = []
const brain = await makeBrain({
name: 'recording-graph-g2',
activate: async (ctx: any) => {
ctx.registerProvider('graphIndex', makeRecordingGraphFactory(calls))
return true
}
})
const a = await brain.add({ data: 'a', type: NounType.Person, vector: V() })
const b = await brain.add({ data: 'b', type: NounType.Person, vector: V() })
const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith })
calls.length = 0
await brain.transact([{ op: 'updateRelation', id: relId, type: VerbType.ReportsTo }] as any)
// planTxUpdateRelation took the SAME update-op branch as updateRelation()
// (see pin (f)) — one updateVerb call, not the pair.
expect(calls.filter((c) => c.id === relId)).toEqual([{ method: 'updateVerb', id: relId }])
const meta = await (brain as any).storage.getVerbMetadata(relId)
expect(meta?.verb).toBe(VerbType.ReportsTo)
})
it('an unknown id rejects the whole batch — other ops in it do not apply', async () => {
const brain = await makeBrain({ name: 'plain-g3', activate: async () => true })
const newId = freshId()
await expect(
brain.transact([
{ op: 'add', id: newId, data: 'never lands', type: NounType.Concept, vector: V() },
{ op: 'updateRelation', id: freshId(), subtype: 'ghost' }
] as any)
).rejects.toBeInstanceOf(RelationNotFoundError)
expect(await brain.get(newId)).toBeNull()
})
})