feat(update-seam): first-class update operation through the provider seam
All checks were successful
CI / Node 24 (push) Successful in 12m19s
CI / Node 22 (push) Successful in 12m30s
CI / Integration + conformance (Node 22) (push) Successful in 18m26s
CI / Bun (latest) (push) Successful in 12m16s

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:
David Snelling 2026-08-24 09:50:35 -07:00
parent 7c8c8be30c
commit 0c028dfc81
11 changed files with 1090 additions and 66 deletions

View file

@ -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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
])
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<string, unknown>
})
}
}
]
: 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<T>,
existingRec: GraphVerb
): {
typeChanged: boolean
newMetadata: Record<string, unknown>
updatedMetadata: Record<string, unknown>
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<T = any> implements BrainyInterface<T> {
// 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<T = any> implements BrainyInterface<T> {
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<string, unknown>
})
}
}
]
: undefined)
return {
typeChanged,
newMetadata: newMetadata as Record<string, unknown>,
updatedMetadata,
verbForIndex
}
}
/**
@ -7958,6 +8038,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
} 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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
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<TxOperation<T>, { op: 'updateRelation' }>,
state: TxPlanState,
plan: PlannedTransact
): Promise<string> {
const { op: _discriminator, ...rawParams } = op
validateUpdateRelationParams(rawParams as UpdateRelationParams<T>)
const params = rawParams as UpdateRelationParams<T>
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<string, unknown> })
}
})
}
return params.id
}
/** Plan one `{ op: 'unrelate' }` — mirror of `unrelate()`. Returns the relationship id. */
private async planTxUnrelate(
op: Extract<TxOperation<T>, { op: 'unrelate' }>,

View file

@ -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<T = any> {
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<Relation<T> | 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<T = any> {
}
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<string, unknown> | 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

View file

@ -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<T = any> extends RelateParams<T> {
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<T = any> extends UpdateRelationParams<T> {
/** Discriminator. */
op: 'updateRelation'
}
/**
* @description Delete a relationship by id (mirror of `brain.unrelate()`).
*/
@ -96,6 +108,7 @@ export type TxOperation<T = any> =
| TxUpdateOperation<T>
| TxRemoveOperation
| TxRelateOperation<T>
| TxUpdateRelationOperation<T>
| TxUnrelateOperation
/**

View file

@ -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)
}
}
}

View file

@ -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 =============

View file

@ -307,6 +307,48 @@ export interface MetadataIndexProvider {
*/
removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise<void>
/**
* @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<string>
/**
* @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<void>
getIds(field: string, value: any): Promise<string[]>
/**
* Resolve a `where` filter to its matching ids.
@ -573,6 +615,44 @@ export interface GraphIndexProvider {
*/
removeVerb(verbId: string, generation: bigint): Promise<void>
/**
* @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<string>
/**
* @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<void>
rebuild(): Promise<void>
flush(): Promise<void>
close(): Promise<void>

View file

@ -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<RollbackAction> {
// 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<RollbackAction> {
// 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}).

View file

@ -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'

View file

@ -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<void>
}
/**
* @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<void>
}
/**
* @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')
}
}