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).
409 lines
17 KiB
TypeScript
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()
|
|
})
|
|
})
|