diff --git a/docs/api/README.md b/docs/api/README.md index 4ca84364..f5860574 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -848,6 +848,7 @@ const db = await brain.transact([ { op: 'add', id: orderId, type: NounType.Document, subtype: 'order', data: 'Order #1042' }, { op: 'update', id: customerId, metadata: { lastOrderAt: Date.now() }, ifRev: customer._rev }, { op: 'relate', from: customerId, to: orderId, type: VerbType.Creates, subtype: 'purchase' }, + { op: 'updateRelation', id: purchaseRelationId, subtype: 'return' }, { op: 'remove', id: staleDraftId }, { op: 'unrelate', id: oldRelationId } ], { @@ -864,6 +865,7 @@ db.receipt.generation // the committed generation - `{ op: 'update', ... }` — same parameters as `update()`, including per-entity `ifRev` CAS - `{ op: 'remove', id }` — deletes the entity plus its relationships (same cascade as `delete()`) - `{ op: 'relate', ... }` — same parameters as `relate()`, including `bidirectional`; duplicates dedupe to the existing relationship id +- `{ op: 'updateRelation', ... }` — same parameters as `updateRelation()`; a batchable, first-class relationship update (not `unrelate` + `relate` — the relationship id and its edge never change) - `{ op: 'unrelate', id }` — deletes a relationship by id Operations may reference ids created earlier in the same batch. diff --git a/src/brainy.ts b/src/brainy.ts index 6ebce2fd..5a181535 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -103,7 +103,12 @@ import { UpdateNounMetadataOperation, UpdateVerbMetadataOperation, DeleteNounMetadataOperation, - DeleteVerbMetadataOperation + DeleteVerbMetadataOperation, + UpdateInMetadataIndexOperation, + UpdateVerbInGraphIndexOperation, + metadataUpdateOpProvider, + graphUpdateOpProvider, + assertUpdateCapabilityCoherent } from './transaction/operations/index.js' import { BaseOperationalMode, @@ -196,7 +201,7 @@ import { } from './events/changeFeed.js' import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js' import { GenerationConflictError, StoreInconsistentError } from './db/errors.js' -import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError, VectorIndexNotReadyError } from './errors/brainyError.js' +import { BrainyError, GraphIndexNotReadyError, MetadataIndexNotReadyError, MigrationInProgressError, VectorIndexNotReadyError, ProviderCapabilityMismatchError } from './errors/brainyError.js' import { assessIndexReadiness } from './utils/indexReadiness.js' import { reconstructNounWrapper } from './db/factLog.js' import { asBrainyFieldRefusal } from './db/fieldAddressing.js' @@ -1276,6 +1281,11 @@ export class Brainy implements BrainyInterface { entityIdMapper: entityIdMapperFactory ? entityIdMapperFactory(this.storage) : undefined, }) } + // Registration-time refusal (before any write can run): a provider + // whose `capabilities` set claims 'update-op' while its instance lacks + // `updateIndex` is a typed, loud refusal — never a silent fallback + // discovered only at the first write. + assertUpdateCapabilityCoherent(this.metadataIndex, 'metadata') // Provider: graph index factory const graphFactory = this.pluginRegistry.getProvider<(storage: StorageAdapter) => any>('graphIndex') @@ -1290,6 +1300,9 @@ export class Brainy implements BrainyInterface { ]) this.graphIndex = graphIndex } + // Same registration-time refusal, graph half (see the metadata-index + // check above). + assertUpdateCapabilityCoherent(this.graphIndex, 'graph') // Fact-log v2 mint seam: after-image records carry minted dense ints, // and the ONE authority for those assignments is the metadata index's @@ -1682,6 +1695,14 @@ export class Brainy implements BrainyInterface { if (error instanceof Error && (error as Error & { code?: string }).code === 'BRAINY_WRITER_LOCKED') { throw error } + // Same rationale, provider-capability half: a registration-time refusal + // (a provider's `capabilities` set lies about implementing 'update-op') + // carries a machine-readable `.type`/`.family`/`.missingMethod` — the + // whole point of the typed-error family — so it must not be flattened + // into a message-only generic Error either. + if (error instanceof ProviderCapabilityMismatchError) { + throw error + } throw new Error(`Failed to initialize Brainy: ${error}`) } } @@ -3619,12 +3640,23 @@ export class Brainy implements BrainyInterface { createdBy: existing.createdBy, metadata: existing.metadata // CRITICAL: keep as nested 'metadata' property! } - tx.addOperation( - new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration) - ) - tx.addOperation( - new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration) - ) + // ONE atomic metadata-index leg when the provider announces the + // update-op capability (both halves of the check pass), else the + // legacy remove-old/add-new pair — the one-train overlap this + // release (see MetadataIndexProvider.updateIndex in src/plugin.ts). + const metadataUpdateProvider = metadataUpdateOpProvider(this.metadataIndex) + if (metadataUpdateProvider) { + tx.addOperation( + new UpdateInMetadataIndexOperation(metadataUpdateProvider, params.id, removalMetadata, entityForIndexing, this.indexWriteGeneration) + ) + } else { + tx.addOperation( + new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration) + ) + tx.addOperation( + new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration) + ) + } }, casPrecommit, this._changeFeed.hasListeners ? [ { @@ -4837,16 +4869,103 @@ export class Brainy implements BrainyInterface { validateUpdateRelationParams(params) - const existing = await this.storage.getVerb(params.id) if (!existing) { throw new RelationNotFoundError(params.id) } - // Legacy stored shapes carried the verb type under `type` instead of the - // canonical `verb` field — read both, canonical first. - const existingRec: HNSWVerbWithMetadata & { type?: VerbType } = existing - const newVerbType = params.type ?? existingRec.verb ?? existingRec.type + const { typeChanged, verbForIndex, updatedMetadata } = this.buildUpdateRelationRecord(params, existing) + + // 8.0 BigInt boundary: endpoints are unchanged across a type swap, so one + // resolution serves both the remove (rollback re-add) and the re-add — + // only needed on the legacy pair path below; the update-op path never + // touches endpoint ints (the provider already holds the mapping). + const reindexInts = typeChanged ? this.resolveVerbEndpointInts(verbForIndex) : undefined + + await this.persistSingleOp({ verbs: [params.id] }, async (tx) => { + tx.addOperation( + new UpdateVerbMetadataOperation(this.storage, params.id, updatedMetadata) + ) + + // If the verb type changed, re-index in graph adjacency so traversal-by-type + // stays consistent. The id is preserved across the swap. ONE atomic + // update-op leg when the graph provider announces the capability, else + // the legacy remove-old/add-new pair — the one-train overlap this + // release. + if (typeChanged && reindexInts) { + const graphUpdateProvider = graphUpdateOpProvider(this.graphIndex) + if (graphUpdateProvider) { + tx.addOperation( + new UpdateVerbInGraphIndexOperation(graphUpdateProvider, existing, verbForIndex, this.graphWriteGeneration) + ) + } else { + tx.addOperation( + new RemoveFromGraphIndexOperation( + this.graphIndex, existing, reindexInts, this.graphWriteGeneration + ) + ) + tx.addOperation( + new AddToGraphIndexOperation( + this.graphIndex, verbForIndex, reindexInts, + this.graphWriteGeneration, + (verbInt) => this.cacheVerbInt(verbInt, params.id) + ) + ) + } + } + }, + undefined, + this._changeFeed.hasListeners + ? [ + { + kind: 'relation', + op: 'updateRelation', + id: params.id, + relation: { + id: params.id, + from: verbForIndex.sourceId, + to: verbForIndex.targetId, + type: String(verbForIndex.verb ?? verbForIndex.type), + ...(verbForIndex.metadata && { + metadata: verbForIndex.metadata as Record + }) + } + } + ] + : undefined) + } + + /** + * Pure record-building core shared by `updateRelation()` and + * `planTxUpdateRelation()`: given the caller-resolved before-image verb, + * runs subtype enforcement and computes the merged v2 metadata record and + * the graph-index view of the after-image — WITHOUT touching storage. The + * actual read of the before-image (`storage.getVerb` for the single-op + * path; batch-state-aware resolution for the transact planner, so a verb + * updated or created earlier in the SAME batch is visible) stays with the + * caller — the two differ, mirroring why `update()`/`planTxUpdate` each + * own their own entity read rather than sharing one. + * + * @param params - The update params. + * @param existingRec - The verb's REAL before-image (legacy stored shapes + * carried the verb type under `type` instead of the canonical `verb` + * field — this reads both, canonical first). + * @returns `typeChanged` (whether the effective verb type differs from the + * before-image), the merged `newMetadata` user bag, the full + * `updatedMetadata` v2 record to persist, and `verbForIndex` — the + * graph-index view of the after-image (its `id` matches + * `existingRec.id` — updates never change a verb's id). + */ + private buildUpdateRelationRecord( + params: UpdateRelationParams, + existingRec: GraphVerb + ): { + typeChanged: boolean + newMetadata: Record + updatedMetadata: Record + verbForIndex: GraphVerb + } { + const newVerbType = (params.type ?? existingRec.verb ?? existingRec.type) as VerbType // Subtype pairing enforcement on update (7.30.0). The effective verb type after // the update may have changed; we check against the new type and the resulting @@ -4898,7 +5017,7 @@ export class Brainy implements BrainyInterface { // Build the verb view used by the graph index — top-level fields mirror relate()'s. const verbForIndex: GraphVerb = { - id: params.id, + id: existingRec.id, vector: existingRec.vector, sourceId: existingRec.sourceId, targetId: existingRec.targetId, @@ -4916,51 +5035,12 @@ export class Brainy implements BrainyInterface { createdAt: existingRec.createdAt } - // 8.0 BigInt boundary: endpoints are unchanged across a type swap, so one - // resolution serves both the remove (rollback re-add) and the re-add. - const reindexInts = typeChanged ? this.resolveVerbEndpointInts(verbForIndex) : undefined - - await this.persistSingleOp({ verbs: [params.id] }, async (tx) => { - tx.addOperation( - new UpdateVerbMetadataOperation(this.storage, params.id, updatedMetadata) - ) - - // If the verb type changed, re-index in graph adjacency so traversal-by-type - // stays consistent. The id is preserved across the swap. - if (typeChanged && reindexInts) { - tx.addOperation( - new RemoveFromGraphIndexOperation( - this.graphIndex, existing, reindexInts, this.graphWriteGeneration - ) - ) - tx.addOperation( - new AddToGraphIndexOperation( - this.graphIndex, verbForIndex, reindexInts, - this.graphWriteGeneration, - (verbInt) => this.cacheVerbInt(verbInt, params.id) - ) - ) - } - }, - undefined, - this._changeFeed.hasListeners - ? [ - { - kind: 'relation', - op: 'updateRelation', - id: params.id, - relation: { - id: params.id, - from: verbForIndex.sourceId, - to: verbForIndex.targetId, - type: String(verbForIndex.verb ?? verbForIndex.type), - ...(verbForIndex.metadata && { - metadata: verbForIndex.metadata as Record - }) - } - } - ] - : undefined) + return { + typeChanged, + newMetadata: newMetadata as Record, + updatedMetadata, + verbForIndex + } } /** @@ -7958,6 +8038,9 @@ export class Brainy implements BrainyInterface { entityIdMapper: entityIdMapperFactory ? entityIdMapperFactory(this.storage) : undefined, }) } + // Registration-time refusal, re-run on every re-adoption (see init()'s + // matching check) — before any write can run against the recreated index. + assertUpdateCapabilityCoherent(this.metadataIndex, 'metadata') await this.metadataIndex.init() // Re-resolve the graph index the same way init() does (provider factory, @@ -7972,6 +8055,8 @@ export class Brainy implements BrainyInterface { } else { this.graphIndex = await this.storage.getGraphIndex() } + // Same registration-time refusal, graph half. + assertUpdateCapabilityCoherent(this.graphIndex, 'graph') this.wireGraphIdResolver() // Reset dimensions @@ -10159,6 +10244,9 @@ export class Brainy implements BrainyInterface { case 'relate': plan.ids.push(await this.planTxRelate(op, state, plan)) break + case 'updateRelation': + plan.ids.push(await this.planTxUpdateRelation(op, state, plan)) + break case 'unrelate': plan.ids.push(await this.planTxUnrelate(op, state, plan)) break @@ -10530,10 +10618,21 @@ export class Brainy implements BrainyInterface { new ReplaceInVectorIndexOperation(this.index, params.id, existing.vector, vector, this.indexWriteGeneration) ) } - plan.operations.push( - new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration), - new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration) - ) + // ONE atomic metadata-index leg when the provider announces the + // update-op capability, else the legacy remove-old/add-new pair — same + // branch as update() (see the class note above planTxUpdate's Object 5-6 + // sibling in update()). + const metadataUpdateProviderTx = metadataUpdateOpProvider(this.metadataIndex) + if (metadataUpdateProviderTx) { + plan.operations.push( + new UpdateInMetadataIndexOperation(metadataUpdateProviderTx, params.id, removalMetadata, entityForIndexing, this.indexWriteGeneration) + ) + } else { + plan.operations.push( + new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration), + new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration) + ) + } plan.touchedNouns.push(params.id) // The full planGetEntity view, passed whole — a subset view makes the @@ -10861,6 +10960,82 @@ export class Brainy implements BrainyInterface { return id } + /** + * Plan one `{ op: 'updateRelation' }` — mirror of `updateRelation()`, + * batch-aware: resolves the before-image against "current state + batch + * so far" (a `relate` or an earlier `updateRelation` on the SAME id + * earlier in this batch is visible), so the op is genuinely batchable — + * the graph counterpart of `planTxUpdate`. Returns the relationship id. + */ + private async planTxUpdateRelation( + op: Extract, { op: 'updateRelation' }>, + state: TxPlanState, + plan: PlannedTransact + ): Promise { + const { op: _discriminator, ...rawParams } = op + validateUpdateRelationParams(rawParams as UpdateRelationParams) + const params = rawParams as UpdateRelationParams + + const existing = state.removedVerbs.has(params.id) + ? null + : (state.verbs.get(params.id) ?? (await this.storage.getVerb(params.id))) + if (!existing) { + throw new RelationNotFoundError(params.id) + } + + const { typeChanged, verbForIndex, updatedMetadata } = this.buildUpdateRelationRecord(params, existing) + + plan.operations.push( + new UpdateVerbMetadataOperation(this.storage, params.id, updatedMetadata) + ) + + // If the verb type changed, re-index in graph adjacency — same branch as + // updateRelation(): ONE atomic update-op leg when the graph provider + // announces the capability, else the legacy remove-old/add-new pair. + if (typeChanged) { + const graphUpdateProvider = graphUpdateOpProvider(this.graphIndex) + if (graphUpdateProvider) { + plan.operations.push( + new UpdateVerbInGraphIndexOperation(graphUpdateProvider, existing, verbForIndex, this.graphWriteGeneration) + ) + } else { + plan.operations.push( + // Endpoint ints resolve at EXECUTE time — mirror of planTxRelate/ + // planTxUnrelate: the verb (or its endpoints) may have been + // created earlier in this same batch. + new RemoveFromGraphIndexOperation( + this.graphIndex, existing, () => this.resolveVerbEndpointInts(verbForIndex), this.graphWriteGeneration + ), + new AddToGraphIndexOperation( + this.graphIndex, verbForIndex, () => this.resolveVerbEndpointInts(verbForIndex), + this.graphWriteGeneration, + (verbInt) => this.cacheVerbInt(verbInt, params.id) + ) + ) + } + } + + plan.touchedVerbs.push(params.id) + state.verbs.set(params.id, verbForIndex) + + if (this._changeFeed.hasListeners) { + plan.changeEvents.push({ + kind: 'relation', + op: 'updateRelation', + id: params.id, + relation: { + id: params.id, + from: verbForIndex.sourceId, + to: verbForIndex.targetId, + type: String(verbForIndex.verb ?? verbForIndex.type), + ...(verbForIndex.metadata && { metadata: verbForIndex.metadata as Record }) + } + }) + } + + return params.id + } + /** Plan one `{ op: 'unrelate' }` — mirror of `unrelate()`. Returns the relationship id. */ private async planTxUnrelate( op: Extract, { op: 'unrelate' }>, diff --git a/src/db/db.ts b/src/db/db.ts index 68428a7c..8e69727d 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -61,7 +61,7 @@ import { exportGraph } from './portableGraph.js' import type { ExportSelector, ExportOptions, PortableGraph } from './portableGraph.js' import { v4 as uuidv4 } from '../universal/uuid.js' import { coerceNewEntityId, resolveEntityId, ORIGINAL_ID_KEY } from '../utils/idNormalization.js' -import { EntityNotFoundError } from '../errors/notFound.js' +import { EntityNotFoundError, RelationNotFoundError } from '../errors/notFound.js' import { SpeculativeOverlayError, CanonicalEnumerationUnavailableError } from './errors.js' import type { GenerationStore } from './generationStore.js' import type { ChangedIds, TransactReceipt, TxOperation } from './types.js' @@ -698,6 +698,39 @@ export class Db { return this.get(id) } + // The verb counterpart of `speculativeGet`. No `Db.getRelation(id)` + // exists (only the array-returning `related()`), so this replicates its + // generation-aware single-id resolution: the overlay first, then the + // generational before-image if something after this pin touched the + // verb (the same `resolveAt('verb', …)` + `relationFromRecord` pairing + // `related()` uses for its own changed-but-not-overlaid merge), else the + // live stored verb — nothing after this pin touched it, so the live + // state IS this view's state. + const speculativeGetRelation = async (id: string): Promise | null> => { + if (overlay.verbs.has(id)) return overlay.verbs.get(id) ?? null + const resolved = await this.host.store.resolveAt('verb', id, this.gen) + if (resolved.source === 'absent') return null + if (resolved.source === 'record') { + return this.host.relationFromRecord(id, { metadata: resolved.metadata, vector: resolved.vector }) + } + const stored = await this.host.storage.getVerb(id) + if (!stored) return null + return { + id: stored.id, + from: stored.sourceId, + to: stored.targetId, + type: stored.verb, + ...(stored.subtype !== undefined && { subtype: stored.subtype }), + ...(stored.visibility !== undefined && { visibility: stored.visibility }), + weight: stored.weight ?? 1.0, + ...(stored.confidence !== undefined && { confidence: stored.confidence }), + data: stored.data, + metadata: (stored.metadata ?? {}) as T, + ...(stored.service !== undefined && { service: stored.service }), + createdAt: stored.createdAt + } + } + for (const op of ops) { switch (op.op) { case 'add': { @@ -855,6 +888,35 @@ export class Db { } break } + case 'updateRelation': { + const base = await speculativeGetRelation(op.id) + if (!base) { + throw new RelationNotFoundError( + op.id, + `with(): relationship ${op.id} not found at generation ${this.gen}` + ) + } + // Field-addressing law — mirror of the 'update' case: the patch + // bag is the user's verbatim; engine scalars only from dedicated + // op fields. + const custom = { ...(op.metadata as Record | undefined) } + const mergedMetadata = + op.merge !== false + ? ({ ...(base.metadata as object), ...custom } as T) + : ((op.metadata !== undefined ? custom : base.metadata) as T) + overlay.verbs.set(op.id, { + ...base, + ...(op.type !== undefined && { type: op.type }), + ...(op.subtype !== undefined && { subtype: op.subtype }), + ...(op.visibility !== undefined && { visibility: op.visibility }), + ...(op.weight !== undefined && { weight: op.weight }), + ...(op.confidence !== undefined && { confidence: op.confidence }), + ...(op.data !== undefined && { data: op.data }), + metadata: mergedMetadata, + updatedAt: Date.now() + }) + break + } case 'unrelate': { overlay.verbs.set(op.id, null) break diff --git a/src/db/types.ts b/src/db/types.ts index 363de086..c681ad9c 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -25,7 +25,7 @@ * `docs/ADR-001-generational-mvcc.md` for the full justification. */ -import type { AddParams, UpdateParams, RelateParams, Entity, Relation } from '../types/brainy.types.js' +import type { AddParams, UpdateParams, RelateParams, UpdateRelationParams, Entity, Relation } from '../types/brainy.types.js' // ============================================================================ // Transaction operations (brain.transact input) @@ -75,6 +75,18 @@ export interface TxRelateOperation extends RelateParams { op: 'relate' } +/** + * @description Update a relationship. Carries the same parameters as + * `brain.updateRelation()` — a first-class batch op (not merely `unrelate` + * + `relate`), so a type change re-indexes the SAME relationship id rather + * than minting a new one, and a batch containing it is rejected atomically + * (like every other op here) if the relationship id does not exist. + */ +export interface TxUpdateRelationOperation extends UpdateRelationParams { + /** Discriminator. */ + op: 'updateRelation' +} + /** * @description Delete a relationship by id (mirror of `brain.unrelate()`). */ @@ -96,6 +108,7 @@ export type TxOperation = | TxUpdateOperation | TxRemoveOperation | TxRelateOperation + | TxUpdateRelationOperation | TxUnrelateOperation /** diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index a58236e3..18299405 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -18,6 +18,7 @@ export type BrainyErrorType = | 'PROTECTED_ARTIFACT' | 'DERIVED_ARTIFACT_MISSING' | 'MIGRATION_IN_PROGRESS' + | 'PROVIDER_CAPABILITY_MISMATCH' /** * Custom error class for Brainy operations @@ -405,3 +406,43 @@ export class MigrationInProgressError extends BrainyError { } } } + +/** + * Thrown at PROVIDER REGISTRATION when an index-provider instance (the + * `'metadataIndex'` or `'graphIndex'` provider a native accelerator + * registers) announces a capability in its `capabilities` set that its own + * methods do not actually implement — e.g. the set contains `'update-op'` + * but the instance has no `updateIndex`/`updateVerb` function. A provider + * must never claim more than it delivers: honoring the announcement would + * let brainy emit an update op the provider cannot execute, discovered only + * at the first write instead of at startup. This is the TYPED REFUSAL AT + * REGISTRATION — loud and immediate, never a silent fallback to the legacy + * remove+add pair for a provider that lied about its capabilities. + * + * Raised by `assertUpdateCapabilityCoherent` + * (`src/transaction/operations/updateCapability.ts`), called once per + * provider at adoption time, before any write can run. + */ +export class ProviderCapabilityMismatchError extends BrainyError { + /** Which provider family failed the check. */ + public readonly family: 'metadata' | 'graph' + /** The method the announced capability required but the provider lacks. */ + public readonly missingMethod: string + + constructor(family: 'metadata' | 'graph', missingMethod: string) { + super( + `Provider capability mismatch: the '${family}' index provider's ` + + `\`capabilities\` set claims 'update-op' but does not expose a ` + + `\`${missingMethod}\` method — a provider must not announce a ` + + `capability it does not implement. Registration refused.`, + 'PROVIDER_CAPABILITY_MISMATCH', + false + ) + this.name = 'ProviderCapabilityMismatchError' + this.family = family + this.missingMethod = missingMethod + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ProviderCapabilityMismatchError) + } + } +} diff --git a/src/index.ts b/src/index.ts index 765c20b4..285fed6d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -202,7 +202,7 @@ export { EntityNotFoundError, RelationNotFoundError } from './errors/notFound.js // Base error + typed migration-lock error — thrown by any data-plane call while a // brain runs its one-time 7.x→8.0 upgrade; catch to answer HTTP 503 + Retry-After. -export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError } from './errors/brainyError.js' +export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError, ProviderCapabilityMismatchError } from './errors/brainyError.js' export type { BrainyErrorType } from './errors/brainyError.js' // ============= 8.0 Db API — generational MVCC ============= diff --git a/src/plugin.ts b/src/plugin.ts index 947c86a5..4068e6dd 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -307,6 +307,48 @@ export interface MetadataIndexProvider { */ removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise + /** + * @description OPTIONAL. The capability set this provider INSTANCE + * announces — e.g. `{'update-op'}` for {@link updateIndex}. Absence means + * "no additional capabilities": brainy keeps this provider on the legacy + * remove+add pair (`removeFromIndex` then `addToIndex`) for every update, + * unchanged — the one-train overlap this release. A provider whose set + * claims a capability its methods do not actually implement is a TYPED + * REFUSAL AT REGISTRATION (loud, never a silent fallback) — see + * `assertUpdateCapabilityCoherent` in + * `src/transaction/operations/updateCapability.ts`, called once per + * provider at adoption, before any write can run. + */ + capabilities?: ReadonlySet + + /** + * @description OPTIONAL FIRST-CLASS UPDATE. Mutates one entity's indexed + * metadata from `before` to `after` IN PLACE — the cure for the historical + * remove+add pair, whose two separately-awaited legs let a provider that + * keys derived structures by entity identity destroy state on the remove + * leg that the add leg needed back (four production regressions in five + * releases lived at that seam). `before`/`after` are the SAME + * entity-for-indexing shapes {@link removeFromIndex}/{@link addToIndex} + * receive; the optional trailing `generation` mirrors {@link addToIndex}'s. + * + * Brainy emits this op ONLY when BOTH halves of the capability check pass: + * `provider.capabilities?.has('update-op') && typeof provider.updateIndex + * === 'function'`. Absence of the capability set keeps the provider on the + * legacy remove+add pair path, which remains supported for (at least) one + * overlap release. + * + * Rollback is symmetric BY CONSTRUCTION: `updateIndex(id, after, before)` + * undoes `updateIndex(id, before, after)` — the same resolved generation is + * reused for both, exactly like the existing add/remove op pairs. + * @param id - The entity's UUID. + * @param before - The entity's REAL before-image (the existing indexed + * shape read on the update path) — never invented. + * @param after - The entity's after-image (the new indexed shape). + * @param generation - OPTIONAL commit generation, same contract as + * {@link addToIndex}. + */ + updateIndex?(id: string, before: any, after: any, generation?: bigint): Promise + getIds(field: string, value: any): Promise /** * Resolve a `where` filter to its matching ids. @@ -573,6 +615,44 @@ export interface GraphIndexProvider { */ removeVerb(verbId: string, generation: bigint): Promise + /** + * @description OPTIONAL. The capability set this provider INSTANCE + * announces — e.g. `{'update-op'}` for {@link updateVerb}. Absence means + * "no additional capabilities": brainy keeps this provider on the legacy + * remove+add pair (`removeVerb` then `addVerb`) for every verb update, + * unchanged — the one-train overlap this release. A provider whose set + * claims a capability its methods do not actually implement is a TYPED + * REFUSAL AT REGISTRATION (loud, never a silent fallback) — see + * `assertUpdateCapabilityCoherent` in + * `src/transaction/operations/updateCapability.ts`, called once per + * provider at adoption, before any write can run. + */ + capabilities?: ReadonlySet + + /** + * @description OPTIONAL FIRST-CLASS UPDATE for one verb — the graph + * counterpart of {@link MetadataIndexProvider.updateIndex}. Endpoints + * NEVER change across an update (only type/metadata do); the provider + * already holds the verb's int mapping, so it mutates the existing edge + * record in place instead of removing and re-adding it. + * + * Brainy emits this op ONLY when BOTH halves of the capability check pass: + * `provider.capabilities?.has('update-op') && typeof provider.updateVerb + * === 'function'`. Absence of the capability set keeps the provider on the + * legacy remove+add pair path (`removeVerb` + `addVerb`), which remains + * supported for (at least) one overlap release. + * + * Rollback is symmetric BY CONSTRUCTION: `updateVerb(id, afterVerb, + * beforeVerb, generation)` undoes `updateVerb(id, beforeVerb, afterVerb, + * generation)` — the same generation is reused for both. + * @param id - The verb's UUID string. + * @param beforeVerb - The verb's REAL before-image (the existing stored verb). + * @param afterVerb - The verb's after-image. + * @param generation - Brainy's commit generation for this write — same + * contract as {@link addVerb} (required, never a fabricated 0). + */ + updateVerb?(id: string, beforeVerb: GraphVerb, afterVerb: GraphVerb, generation: bigint): Promise + rebuild(): Promise flush(): Promise close(): Promise diff --git a/src/transaction/operations/IndexOperations.ts b/src/transaction/operations/IndexOperations.ts index 139c67fe..8c86f418 100644 --- a/src/transaction/operations/IndexOperations.ts +++ b/src/transaction/operations/IndexOperations.ts @@ -13,6 +13,7 @@ import type { VectorIndexProvider, GraphIndexProvider } from '../../plugin.js' import type { MetadataIndexManager } from '../../utils/metadataIndex.js' import type { GraphVerb } from '../../coreTypes.js' import type { Operation, RollbackAction } from '../types.js' +import type { MetadataUpdateOpProvider, GraphUpdateOpProvider } from './updateCapability.js' /** * Backend identity stamped into an operation's emitted `name` string (e.g. @@ -374,6 +375,59 @@ export class RemoveFromMetadataIndexOperation implements Operation { } } +/** + * Update metadata index IN PLACE via the provider's native update op — the + * FIRST-CLASS UPDATE the accelerator seam gained to replace the historical + * remove+add pair (see `MetadataIndexProvider.updateIndex` in + * `src/plugin.ts`). NEVER construct this directly against a raw provider + * reference — the `index` argument must come from + * {@link import('./updateCapability.js').metadataUpdateOpProvider}, the ONLY + * place that verifies the provider both announces `'update-op'` AND exposes + * the method (the both-halves check). + * + * Rollback strategy: + * - Call `updateIndex` again with `before`/`after` swapped — symmetric BY + * CONSTRUCTION (the provider mutates the SAME record back). + * + * Generation: `generationFn` is resolved at execute time (not construction) — + * see {@link AddToMetadataIndexOperation}'s class note; the same resolved + * value is reused for the rollback so the update and its undo reference one + * watermark in a provider's per-record delta log. + */ +export class UpdateInMetadataIndexOperation implements Operation { + readonly name = 'UpdateInMetadataIndex' + + /** + * @param index - The update-capable surface returned by + * {@link import('./updateCapability.js').metadataUpdateOpProvider}. + * @param id - The entity's UUID. + * @param before - The entity's REAL before-image (the existing indexed + * shape read on the update path) — never invented. + * @param after - The entity's after-image (the new indexed shape). + * @param generationFn - Resolves the commit generation to stamp this write + * at, evaluated when the operation executes. + */ + constructor( + private readonly index: MetadataUpdateOpProvider, + private readonly id: string, + private readonly before: any, + private readonly after: any, + private readonly generationFn?: () => bigint | undefined + ) {} + + async execute(): Promise { + // Stamp this write at the in-flight commit generation; reuse it for the + // symmetric rollback below. + const generation = this.generationFn?.() + await this.index.updateIndex(this.id, this.before, this.after, generation) + + return async () => { + // Symmetric rollback: same generation, before/after swapped. + await this.index.updateIndex(this.id, this.after, this.before, generation) + } + } +} + /** * Add verb to graph index with rollback support * @@ -499,6 +553,58 @@ export class RemoveFromGraphIndexOperation implements Operation { } } +/** + * Update one verb IN PLACE via the provider's native update op — the graph + * counterpart of {@link UpdateInMetadataIndexOperation}. Endpoints NEVER + * change across an update (only type/metadata do), so unlike + * {@link AddToGraphIndexOperation}/{@link RemoveFromGraphIndexOperation} this + * op carries no endpoint ints — the provider already holds the verb's int + * mapping. NEVER construct this directly against a raw provider reference — + * the `index` argument must come from + * {@link import('./updateCapability.js').graphUpdateOpProvider}, the ONLY + * place that verifies the provider both announces `'update-op'` AND exposes + * the method (the both-halves check). + * + * Rollback strategy: + * - Call `updateVerb` again with `beforeVerb`/`afterVerb` swapped — + * symmetric BY CONSTRUCTION. + * + * Generation: `generationFn` is resolved at execute time (not construction), + * mirroring {@link AddToGraphIndexOperation}; the same resolved value is + * reused for the rollback so the update and its undo reference one watermark. + */ +export class UpdateVerbInGraphIndexOperation implements Operation { + readonly name = 'UpdateVerbInGraphIndex' + + /** + * @param index - The update-capable surface returned by + * {@link import('./updateCapability.js').graphUpdateOpProvider}. + * @param beforeVerb - The verb's REAL before-image (the existing stored verb). + * @param afterVerb - The verb's after-image. Its `id` is the same as + * `beforeVerb`'s — updates never change a verb's id. + * @param generationFn - Resolves the commit generation to stamp this write + * at, evaluated when the operation executes. + */ + constructor( + private readonly index: GraphUpdateOpProvider, + private readonly beforeVerb: GraphVerb, + private readonly afterVerb: GraphVerb, + private readonly generationFn: () => bigint + ) {} + + async execute(): Promise { + // Stamp this write at the in-flight commit generation; reuse it for the + // symmetric rollback below. + const generation = this.generationFn() + await this.index.updateVerb(this.afterVerb.id, this.beforeVerb, this.afterVerb, generation) + + return async () => { + // Symmetric rollback: same generation, before/after swapped. + await this.index.updateVerb(this.afterVerb.id, this.afterVerb, this.beforeVerb, generation) + } + } +} + /** * Batch operation: Add multiple items to the vector index (backend-neutral — * see {@link AddToVectorIndexOperation}). diff --git a/src/transaction/operations/index.ts b/src/transaction/operations/index.ts index 32a69a21..f2a97eba 100644 --- a/src/transaction/operations/index.ts +++ b/src/transaction/operations/index.ts @@ -26,8 +26,19 @@ export { ReplaceInVectorIndexOperation, AddToMetadataIndexOperation, RemoveFromMetadataIndexOperation, + UpdateInMetadataIndexOperation, AddToGraphIndexOperation, RemoveFromGraphIndexOperation, + UpdateVerbInGraphIndexOperation, BatchAddToVectorIndexOperation, BatchAddToMetadataIndexOperation } from './IndexOperations.js' + +// Update-op capability check (the FIRST-CLASS UPDATE seam) +export { + UPDATE_OP_CAPABILITY, + metadataUpdateOpProvider, + graphUpdateOpProvider, + assertUpdateCapabilityCoherent +} from './updateCapability.js' +export type { MetadataUpdateOpProvider, GraphUpdateOpProvider } from './updateCapability.js' diff --git a/src/transaction/operations/updateCapability.ts b/src/transaction/operations/updateCapability.ts new file mode 100644 index 00000000..b6ff6113 --- /dev/null +++ b/src/transaction/operations/updateCapability.ts @@ -0,0 +1,125 @@ +/** + * @module transaction/operations/updateCapability + * @description The capability-check seam for the FIRST-CLASS UPDATE + * operation (`'update-op'`) an index provider may announce on its + * `capabilities` set (see `MetadataIndexProvider`/`GraphIndexProvider` in + * `src/plugin.ts`). + * + * Two responsibilities live here, both implementing the SAME both-halves + * check — never trust the set alone, never trust the method alone: + * + * 1. Narrowing helpers ({@link metadataUpdateOpProvider}, + * {@link graphUpdateOpProvider}) the planner calls at EMISSION time: they + * return the narrowed update-capable surface when a provider both + * announces `'update-op'` AND exposes the method, else `null` — the + * planner branches on that `null`-ness to choose the single update op or + * the legacy remove+add pair. Typed structurally (not the concrete + * `MetadataIndexManager`/`GraphAdjacencyIndex` classes) so the new + * operation classes never need an `as any` cast at the call sites where + * `this.metadataIndex`/`this.graphIndex` are typed as those concrete + * classes even when a provider is registered. + * 2. {@link assertUpdateCapabilityCoherent}, called once per provider at + * REGISTRATION time (before any write can run): a provider whose set + * claims `'update-op'` while its instance lacks the method is a lie the + * engine refuses loudly, via {@link ProviderCapabilityMismatchError} — + * never a silent fallback discovered only at the first write. + */ + +import type { MetadataIndexProvider, GraphIndexProvider } from '../../plugin.js' +import type { GraphVerb } from '../../coreTypes.js' +import { ProviderCapabilityMismatchError } from '../../errors/brainyError.js' + +/** The capability literal a provider's `capabilities` set must contain to opt into the update op. */ +export const UPDATE_OP_CAPABILITY = 'update-op' + +/** + * @description The narrowed metadata-index surface {@link metadataUpdateOpProvider} + * returns once both halves of the capability check pass. + */ +export interface MetadataUpdateOpProvider { + updateIndex(id: string, before: any, after: any, generation?: bigint): Promise +} + +/** + * @description The narrowed graph-index surface {@link graphUpdateOpProvider} + * returns once both halves of the capability check pass. + */ +export interface GraphUpdateOpProvider { + updateVerb(id: string, beforeVerb: GraphVerb, afterVerb: GraphVerb, generation: bigint): Promise +} + +/** + * @description The metadata-index planner branch: BOTH halves, exactly — + * `index.capabilities?.has('update-op') && typeof index.updateIndex === + * 'function'`. Returns the narrowed {@link MetadataUpdateOpProvider} to emit + * a single {@link import('./IndexOperations.js').UpdateInMetadataIndexOperation} + * against, or `null` to keep emitting the legacy remove+add pair. + * @param index - The provider (or the built-in JS manager, which never + * announces the capability and so always resolves to `null`). + */ +export function metadataUpdateOpProvider( + index: MetadataIndexProvider +): MetadataUpdateOpProvider | null { + const updateIndex = index.updateIndex + if (index.capabilities?.has(UPDATE_OP_CAPABILITY) && typeof updateIndex === 'function') { + return { updateIndex: updateIndex.bind(index) } + } + return null +} + +/** + * @description The graph-index planner branch: BOTH halves, exactly — + * `index.capabilities?.has('update-op') && typeof index.updateVerb === + * 'function'`. Returns the narrowed {@link GraphUpdateOpProvider} to emit a + * single {@link import('./IndexOperations.js').UpdateVerbInGraphIndexOperation} + * against, or `null` to keep emitting the legacy remove+add pair. + * @param index - The provider (or the built-in JS adjacency index, which + * never announces the capability and so always resolves to `null`). + */ +export function graphUpdateOpProvider( + index: GraphIndexProvider +): GraphUpdateOpProvider | null { + const updateVerb = index.updateVerb + if (index.capabilities?.has(UPDATE_OP_CAPABILITY) && typeof updateVerb === 'function') { + return { updateVerb: updateVerb.bind(index) } + } + return null +} + +/** + * @description Registration-time refusal: throws + * {@link ProviderCapabilityMismatchError} when `provider.capabilities` + * claims `'update-op'` but the family's required method is absent — a + * provider must never announce more than it delivers. Call this ONCE per + * adopted provider, at adoption (brain init and any provider re-adoption, + * e.g. `clear()`), before any write can run. A provider that does not + * announce the capability at all (the common case — most providers, and the + * built-in JS manager/index, stay on the legacy pair path) passes silently: + * this function only rejects a LIE, never the absence of a claim. + * @param provider - The adopted provider instance (the concrete + * `MetadataIndexManager`/`GraphAdjacencyIndex` classes both `implements` + * their respective interface, so this accepts them directly). + * @param family - Which provider family `provider` is, selecting whether + * `updateIndex` (`'metadata'`) or `updateVerb` (`'graph'`) is required. + * @throws {ProviderCapabilityMismatchError} When the set claims the + * capability but the required method is missing or not a function. + */ +export function assertUpdateCapabilityCoherent( + provider: MetadataIndexProvider | GraphIndexProvider, + family: 'metadata' | 'graph' +): void { + if (!provider.capabilities?.has(UPDATE_OP_CAPABILITY)) { + return + } + // Narrow by the caller-declared family — a plain `MetadataIndexProvider | + // GraphIndexProvider` union does not expose `updateIndex`/`updateVerb` + // directly (each method lives on only one side of the union), so the + // family the caller already knows selects which side to read. + const method = + family === 'metadata' + ? (provider as MetadataIndexProvider).updateIndex + : (provider as GraphIndexProvider).updateVerb + if (typeof method !== 'function') { + throw new ProviderCapabilityMismatchError(family, family === 'metadata' ? 'updateIndex' : 'updateVerb') + } +} diff --git a/tests/integration/update-op-emission.test.ts b/tests/integration/update-op-emission.test.ts new file mode 100644 index 00000000..be2486eb --- /dev/null +++ b/tests/integration/update-op-emission.test.ts @@ -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(['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() + }) +})