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).
This commit is contained in:
parent
7c8c8be30c
commit
0c028dfc81
11 changed files with 1090 additions and 66 deletions
409
tests/integration/update-op-emission.test.ts
Normal file
409
tests/integration/update-op-emission.test.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
/**
|
||||
* @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()
|
||||
})
|
||||
})
|
||||
Reference in a new issue