/** * @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(['update-op']) : undefined async addToIndex( id: string, entityOrMetadata: any, skipFlush = false, deferWrites = false, generation?: bigint ): Promise { 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 { 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 { 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(['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(['update-op']) : undefined async addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint, generation: bigint): Promise { calls.push({ method: 'addVerb', id: verb.id }) return super.addVerb(verb, sourceInt, targetInt, generation) } async removeVerb(verbId: string, generation: bigint): Promise { calls.push({ method: 'removeVerb', id: verbId }) return super.removeVerb(verbId, generation) } async updateVerb(id: string, beforeVerb: GraphVerb, afterVerb: GraphVerb, generation: bigint): Promise { 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 { 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() }) })