feat(update-seam): first-class update operation through the provider seam
Brainy's planner emits a single UpdateInMetadataIndexOperation / UpdateVerbInGraphIndexOperation when the registered provider announces the `update-op` capability (capabilities has 'update-op' AND the method exists — both halves), and the legacy remove+add pair otherwise. A provider whose capability set claims what the instance lacks is refused at registration with a typed ProviderCapabilityMismatchError — never a silent fallback. - MetadataIndexProvider.updateIndex(id, before, after, generation?) and GraphIndexProvider.updateVerb(id, before, after, generation) — optional, rollback symmetric by construction (update(a,b) undone by update(b,a)). - Emission at update(), planTxUpdate() and updateRelation()'s graph leg. - transact() gains op:'updateRelation' (TxUpdateRelationOperation), born batchable; updateRelation()'s record build is shared with the planner. - Both paths live for one overlap release; the pair path retires with the provider-side compensation layer in the following cut. Pinned in tests/integration/update-op-emission.test.ts (7 pins).
This commit is contained in:
parent
7c8c8be30c
commit
0c028dfc81
11 changed files with 1090 additions and 66 deletions
301
src/brainy.ts
301
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<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' }>,
|
||||
|
|
|
|||
64
src/db/db.ts
64
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<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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =============
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}).
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
125
src/transaction/operations/updateCapability.ts
Normal file
125
src/transaction/operations/updateCapability.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue