Compare commits
1 commit
main
...
pair-b/upd
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c028dfc81 |
11 changed files with 1090 additions and 66 deletions
|
|
@ -848,6 +848,7 @@ const db = await brain.transact([
|
||||||
{ op: 'add', id: orderId, type: NounType.Document, subtype: 'order', data: 'Order #1042' },
|
{ op: 'add', id: orderId, type: NounType.Document, subtype: 'order', data: 'Order #1042' },
|
||||||
{ op: 'update', id: customerId, metadata: { lastOrderAt: Date.now() }, ifRev: customer._rev },
|
{ op: 'update', id: customerId, metadata: { lastOrderAt: Date.now() }, ifRev: customer._rev },
|
||||||
{ op: 'relate', from: customerId, to: orderId, type: VerbType.Creates, subtype: 'purchase' },
|
{ op: 'relate', from: customerId, to: orderId, type: VerbType.Creates, subtype: 'purchase' },
|
||||||
|
{ op: 'updateRelation', id: purchaseRelationId, subtype: 'return' },
|
||||||
{ op: 'remove', id: staleDraftId },
|
{ op: 'remove', id: staleDraftId },
|
||||||
{ op: 'unrelate', id: oldRelationId }
|
{ 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: 'update', ... }` — same parameters as `update()`, including per-entity `ifRev` CAS
|
||||||
- `{ op: 'remove', id }` — deletes the entity plus its relationships (same cascade as `delete()`)
|
- `{ 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: '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
|
- `{ op: 'unrelate', id }` — deletes a relationship by id
|
||||||
|
|
||||||
Operations may reference ids created earlier in the same batch.
|
Operations may reference ids created earlier in the same batch.
|
||||||
|
|
|
||||||
301
src/brainy.ts
301
src/brainy.ts
|
|
@ -103,7 +103,12 @@ import {
|
||||||
UpdateNounMetadataOperation,
|
UpdateNounMetadataOperation,
|
||||||
UpdateVerbMetadataOperation,
|
UpdateVerbMetadataOperation,
|
||||||
DeleteNounMetadataOperation,
|
DeleteNounMetadataOperation,
|
||||||
DeleteVerbMetadataOperation
|
DeleteVerbMetadataOperation,
|
||||||
|
UpdateInMetadataIndexOperation,
|
||||||
|
UpdateVerbInGraphIndexOperation,
|
||||||
|
metadataUpdateOpProvider,
|
||||||
|
graphUpdateOpProvider,
|
||||||
|
assertUpdateCapabilityCoherent
|
||||||
} from './transaction/operations/index.js'
|
} from './transaction/operations/index.js'
|
||||||
import {
|
import {
|
||||||
BaseOperationalMode,
|
BaseOperationalMode,
|
||||||
|
|
@ -196,7 +201,7 @@ import {
|
||||||
} from './events/changeFeed.js'
|
} from './events/changeFeed.js'
|
||||||
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
|
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
|
||||||
import { GenerationConflictError, StoreInconsistentError } from './db/errors.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 { assessIndexReadiness } from './utils/indexReadiness.js'
|
||||||
import { reconstructNounWrapper } from './db/factLog.js'
|
import { reconstructNounWrapper } from './db/factLog.js'
|
||||||
import { asBrainyFieldRefusal } from './db/fieldAddressing.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,
|
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
|
// Provider: graph index factory
|
||||||
const graphFactory = this.pluginRegistry.getProvider<(storage: StorageAdapter) => any>('graphIndex')
|
const graphFactory = this.pluginRegistry.getProvider<(storage: StorageAdapter) => any>('graphIndex')
|
||||||
|
|
@ -1290,6 +1300,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
])
|
])
|
||||||
this.graphIndex = graphIndex
|
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,
|
// Fact-log v2 mint seam: after-image records carry minted dense ints,
|
||||||
// and the ONE authority for those assignments is the metadata index's
|
// 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') {
|
if (error instanceof Error && (error as Error & { code?: string }).code === 'BRAINY_WRITER_LOCKED') {
|
||||||
throw error
|
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}`)
|
throw new Error(`Failed to initialize Brainy: ${error}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3619,12 +3640,23 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
createdBy: existing.createdBy,
|
createdBy: existing.createdBy,
|
||||||
metadata: existing.metadata // CRITICAL: keep as nested 'metadata' property!
|
metadata: existing.metadata // CRITICAL: keep as nested 'metadata' property!
|
||||||
}
|
}
|
||||||
tx.addOperation(
|
// ONE atomic metadata-index leg when the provider announces the
|
||||||
new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration)
|
// update-op capability (both halves of the check pass), else the
|
||||||
)
|
// legacy remove-old/add-new pair — the one-train overlap this
|
||||||
tx.addOperation(
|
// release (see MetadataIndexProvider.updateIndex in src/plugin.ts).
|
||||||
new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration)
|
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
|
}, casPrecommit, this._changeFeed.hasListeners
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -4837,16 +4869,103 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
|
|
||||||
validateUpdateRelationParams(params)
|
validateUpdateRelationParams(params)
|
||||||
|
|
||||||
|
|
||||||
const existing = await this.storage.getVerb(params.id)
|
const existing = await this.storage.getVerb(params.id)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new RelationNotFoundError(params.id)
|
throw new RelationNotFoundError(params.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy stored shapes carried the verb type under `type` instead of the
|
const { typeChanged, verbForIndex, updatedMetadata } = this.buildUpdateRelationRecord(params, existing)
|
||||||
// canonical `verb` field — read both, canonical first.
|
|
||||||
const existingRec: HNSWVerbWithMetadata & { type?: VerbType } = existing
|
// 8.0 BigInt boundary: endpoints are unchanged across a type swap, so one
|
||||||
const newVerbType = params.type ?? existingRec.verb ?? existingRec.type
|
// 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
|
// 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
|
// 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.
|
// Build the verb view used by the graph index — top-level fields mirror relate()'s.
|
||||||
const verbForIndex: GraphVerb = {
|
const verbForIndex: GraphVerb = {
|
||||||
id: params.id,
|
id: existingRec.id,
|
||||||
vector: existingRec.vector,
|
vector: existingRec.vector,
|
||||||
sourceId: existingRec.sourceId,
|
sourceId: existingRec.sourceId,
|
||||||
targetId: existingRec.targetId,
|
targetId: existingRec.targetId,
|
||||||
|
|
@ -4916,51 +5035,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
createdAt: existingRec.createdAt
|
createdAt: existingRec.createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8.0 BigInt boundary: endpoints are unchanged across a type swap, so one
|
return {
|
||||||
// resolution serves both the remove (rollback re-add) and the re-add.
|
typeChanged,
|
||||||
const reindexInts = typeChanged ? this.resolveVerbEndpointInts(verbForIndex) : undefined
|
newMetadata: newMetadata as Record<string, unknown>,
|
||||||
|
updatedMetadata,
|
||||||
await this.persistSingleOp({ verbs: [params.id] }, async (tx) => {
|
verbForIndex
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -7958,6 +8038,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
entityIdMapper: entityIdMapperFactory ? entityIdMapperFactory(this.storage) : undefined,
|
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()
|
await this.metadataIndex.init()
|
||||||
|
|
||||||
// Re-resolve the graph index the same way init() does (provider factory,
|
// 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 {
|
} else {
|
||||||
this.graphIndex = await this.storage.getGraphIndex()
|
this.graphIndex = await this.storage.getGraphIndex()
|
||||||
}
|
}
|
||||||
|
// Same registration-time refusal, graph half.
|
||||||
|
assertUpdateCapabilityCoherent(this.graphIndex, 'graph')
|
||||||
this.wireGraphIdResolver()
|
this.wireGraphIdResolver()
|
||||||
|
|
||||||
// Reset dimensions
|
// Reset dimensions
|
||||||
|
|
@ -10159,6 +10244,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
case 'relate':
|
case 'relate':
|
||||||
plan.ids.push(await this.planTxRelate(op, state, plan))
|
plan.ids.push(await this.planTxRelate(op, state, plan))
|
||||||
break
|
break
|
||||||
|
case 'updateRelation':
|
||||||
|
plan.ids.push(await this.planTxUpdateRelation(op, state, plan))
|
||||||
|
break
|
||||||
case 'unrelate':
|
case 'unrelate':
|
||||||
plan.ids.push(await this.planTxUnrelate(op, state, plan))
|
plan.ids.push(await this.planTxUnrelate(op, state, plan))
|
||||||
break
|
break
|
||||||
|
|
@ -10530,10 +10618,21 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
new ReplaceInVectorIndexOperation(this.index, params.id, existing.vector, vector, this.indexWriteGeneration)
|
new ReplaceInVectorIndexOperation(this.index, params.id, existing.vector, vector, this.indexWriteGeneration)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
plan.operations.push(
|
// ONE atomic metadata-index leg when the provider announces the
|
||||||
new RemoveFromMetadataIndexOperation(this.metadataIndex, params.id, removalMetadata, this.indexWriteGeneration),
|
// update-op capability, else the legacy remove-old/add-new pair — same
|
||||||
new AddToMetadataIndexOperation(this.metadataIndex, params.id, entityForIndexing, this.indexWriteGeneration)
|
// 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)
|
plan.touchedNouns.push(params.id)
|
||||||
|
|
||||||
// The full planGetEntity view, passed whole — a subset view makes the
|
// 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
|
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. */
|
/** Plan one `{ op: 'unrelate' }` — mirror of `unrelate()`. Returns the relationship id. */
|
||||||
private async planTxUnrelate(
|
private async planTxUnrelate(
|
||||||
op: Extract<TxOperation<T>, { op: 'unrelate' }>,
|
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 type { ExportSelector, ExportOptions, PortableGraph } from './portableGraph.js'
|
||||||
import { v4 as uuidv4 } from '../universal/uuid.js'
|
import { v4 as uuidv4 } from '../universal/uuid.js'
|
||||||
import { coerceNewEntityId, resolveEntityId, ORIGINAL_ID_KEY } from '../utils/idNormalization.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 { SpeculativeOverlayError, CanonicalEnumerationUnavailableError } from './errors.js'
|
||||||
import type { GenerationStore } from './generationStore.js'
|
import type { GenerationStore } from './generationStore.js'
|
||||||
import type { ChangedIds, TransactReceipt, TxOperation } from './types.js'
|
import type { ChangedIds, TransactReceipt, TxOperation } from './types.js'
|
||||||
|
|
@ -698,6 +698,39 @@ export class Db<T = any> {
|
||||||
return this.get(id)
|
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) {
|
for (const op of ops) {
|
||||||
switch (op.op) {
|
switch (op.op) {
|
||||||
case 'add': {
|
case 'add': {
|
||||||
|
|
@ -855,6 +888,35 @@ export class Db<T = any> {
|
||||||
}
|
}
|
||||||
break
|
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': {
|
case 'unrelate': {
|
||||||
overlay.verbs.set(op.id, null)
|
overlay.verbs.set(op.id, null)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
* `docs/ADR-001-generational-mvcc.md` for the full justification.
|
* `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)
|
// Transaction operations (brain.transact input)
|
||||||
|
|
@ -75,6 +75,18 @@ export interface TxRelateOperation<T = any> extends RelateParams<T> {
|
||||||
op: 'relate'
|
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()`).
|
* @description Delete a relationship by id (mirror of `brain.unrelate()`).
|
||||||
*/
|
*/
|
||||||
|
|
@ -96,6 +108,7 @@ export type TxOperation<T = any> =
|
||||||
| TxUpdateOperation<T>
|
| TxUpdateOperation<T>
|
||||||
| TxRemoveOperation
|
| TxRemoveOperation
|
||||||
| TxRelateOperation<T>
|
| TxRelateOperation<T>
|
||||||
|
| TxUpdateRelationOperation<T>
|
||||||
| TxUnrelateOperation
|
| TxUnrelateOperation
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export type BrainyErrorType =
|
||||||
| 'PROTECTED_ARTIFACT'
|
| 'PROTECTED_ARTIFACT'
|
||||||
| 'DERIVED_ARTIFACT_MISSING'
|
| 'DERIVED_ARTIFACT_MISSING'
|
||||||
| 'MIGRATION_IN_PROGRESS'
|
| 'MIGRATION_IN_PROGRESS'
|
||||||
|
| 'PROVIDER_CAPABILITY_MISMATCH'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom error class for Brainy operations
|
* 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
|
// 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.
|
// 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'
|
export type { BrainyErrorType } from './errors/brainyError.js'
|
||||||
|
|
||||||
// ============= 8.0 Db API — generational MVCC =============
|
// ============= 8.0 Db API — generational MVCC =============
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,48 @@ export interface MetadataIndexProvider {
|
||||||
*/
|
*/
|
||||||
removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise<void>
|
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[]>
|
getIds(field: string, value: any): Promise<string[]>
|
||||||
/**
|
/**
|
||||||
* Resolve a `where` filter to its matching ids.
|
* Resolve a `where` filter to its matching ids.
|
||||||
|
|
@ -573,6 +615,44 @@ export interface GraphIndexProvider {
|
||||||
*/
|
*/
|
||||||
removeVerb(verbId: string, generation: bigint): Promise<void>
|
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>
|
rebuild(): Promise<void>
|
||||||
flush(): Promise<void>
|
flush(): Promise<void>
|
||||||
close(): 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 { MetadataIndexManager } from '../../utils/metadataIndex.js'
|
||||||
import type { GraphVerb } from '../../coreTypes.js'
|
import type { GraphVerb } from '../../coreTypes.js'
|
||||||
import type { Operation, RollbackAction } from '../types.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.
|
* 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
|
* 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 —
|
* Batch operation: Add multiple items to the vector index (backend-neutral —
|
||||||
* see {@link AddToVectorIndexOperation}).
|
* see {@link AddToVectorIndexOperation}).
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,19 @@ export {
|
||||||
ReplaceInVectorIndexOperation,
|
ReplaceInVectorIndexOperation,
|
||||||
AddToMetadataIndexOperation,
|
AddToMetadataIndexOperation,
|
||||||
RemoveFromMetadataIndexOperation,
|
RemoveFromMetadataIndexOperation,
|
||||||
|
UpdateInMetadataIndexOperation,
|
||||||
AddToGraphIndexOperation,
|
AddToGraphIndexOperation,
|
||||||
RemoveFromGraphIndexOperation,
|
RemoveFromGraphIndexOperation,
|
||||||
|
UpdateVerbInGraphIndexOperation,
|
||||||
BatchAddToVectorIndexOperation,
|
BatchAddToVectorIndexOperation,
|
||||||
BatchAddToMetadataIndexOperation
|
BatchAddToMetadataIndexOperation
|
||||||
} from './IndexOperations.js'
|
} 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
409
tests/integration/update-op-emission.test.ts
Normal file
409
tests/integration/update-op-emission.test.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
/**
|
||||||
|
* @module tests/integration/update-op-emission
|
||||||
|
* @description Pins for the FIRST-CLASS UPDATE OPERATION through the
|
||||||
|
* index-provider seam: brainy's planner now emits ONE `updateIndex`/
|
||||||
|
* `updateVerb` call for `update()`/`updateRelation()` (including their
|
||||||
|
* `transact()` op forms) when a registered provider announces the
|
||||||
|
* `'update-op'` capability AND exposes the method (the both-halves check) —
|
||||||
|
* replacing the historical remove+add pair, which remains the emission for
|
||||||
|
* every provider that does not announce the capability (the one-train
|
||||||
|
* overlap this release).
|
||||||
|
*
|
||||||
|
* Recording provider doubles (below) wrap the built-in JS metadata/graph
|
||||||
|
* index managers, delegating every real method to the base class while
|
||||||
|
* recording the call sequence, so each pin below observes brainy's ACTUAL
|
||||||
|
* emission choice rather than mocking the engine.
|
||||||
|
*
|
||||||
|
* Pins:
|
||||||
|
* (a) update() on a capable metadata provider → one updateIndex, zero
|
||||||
|
* removeFromIndex/addToIndex for that id; find() sees the new metadata,
|
||||||
|
* not the old.
|
||||||
|
* (b) the same through transact([{ op: 'update' }]).
|
||||||
|
* (c) rollback: a batch rejected at PLAN time never touches the provider for
|
||||||
|
* the update's id (the row keeps its old metadata); a batch rejected
|
||||||
|
* DURING EXECUTE (a later op's index write fails) rolls the update op
|
||||||
|
* back symmetrically — updateIndex(id, after, before).
|
||||||
|
* (d) a provider whose capabilities claim 'update-op' but lacks updateIndex
|
||||||
|
* is refused at registration with ProviderCapabilityMismatchError.
|
||||||
|
* (e) a provider with no capabilities set gets the legacy pair — both calls
|
||||||
|
* recorded, same commit, the row is never absent from find() between
|
||||||
|
* them from a caller's view.
|
||||||
|
* (f) updateRelation() with a recording GRAPH provider announcing
|
||||||
|
* 'update-op' → exactly one updateVerb call; the relation reads back
|
||||||
|
* with the new type.
|
||||||
|
* (g) transact([{ op: 'updateRelation' }]): merges metadata and reads back;
|
||||||
|
* a type change re-indexes; an unknown id rejects the whole batch (other
|
||||||
|
* ops in it do not apply).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { Brainy } from '../../src/brainy.js'
|
||||||
|
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
||||||
|
import { MetadataIndexManager } from '../../src/utils/metadataIndex.js'
|
||||||
|
import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js'
|
||||||
|
import type { GraphVerb } from '../../src/coreTypes.js'
|
||||||
|
import { EntityNotFoundError, RelationNotFoundError } from '../../src/errors/notFound.js'
|
||||||
|
import { ProviderCapabilityMismatchError } from '../../src/errors/brainyError.js'
|
||||||
|
|
||||||
|
const V = (): number[] => Array.from({ length: 384 }, () => Math.random())
|
||||||
|
|
||||||
|
/** Valid-UUID-shaped deterministic ids so `add`/`relate` never coerce them via the natural-key path. */
|
||||||
|
let seq = 0
|
||||||
|
const freshId = (): string =>
|
||||||
|
`00000000-0000-4000-8000-${(++seq).toString(16).padStart(12, '0')}`
|
||||||
|
|
||||||
|
type RecordedCall = { method: 'addToIndex' | 'removeFromIndex' | 'updateIndex'; id: string }
|
||||||
|
type RecordedVerbCall = { method: 'addVerb' | 'removeVerb' | 'updateVerb'; id: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A metadata-index provider double: wraps the built-in `MetadataIndexManager`,
|
||||||
|
* delegating every real write to the base class while recording the call
|
||||||
|
* sequence. `capable: false` omits the `capabilities` set entirely (legacy
|
||||||
|
* pair path); `failAddFor` injects a targeted `addToIndex` failure so a
|
||||||
|
* later batch op can force a mid-execute rollback (pin c).
|
||||||
|
*/
|
||||||
|
function makeRecordingMetadataFactory(
|
||||||
|
calls: RecordedCall[],
|
||||||
|
opts: { capable?: boolean; failAddFor?: string } = {}
|
||||||
|
): (storage: any) => MetadataIndexManager {
|
||||||
|
const capable = opts.capable !== false
|
||||||
|
return (storage: any) => {
|
||||||
|
class RecordingMetadataProvider extends MetadataIndexManager {
|
||||||
|
capabilities = capable ? new Set<string>(['update-op']) : undefined
|
||||||
|
|
||||||
|
async addToIndex(
|
||||||
|
id: string,
|
||||||
|
entityOrMetadata: any,
|
||||||
|
skipFlush = false,
|
||||||
|
deferWrites = false,
|
||||||
|
generation?: bigint
|
||||||
|
): Promise<void> {
|
||||||
|
if (opts.failAddFor !== undefined && id === opts.failAddFor) {
|
||||||
|
throw new Error(`injected: addToIndex failed for ${id}`)
|
||||||
|
}
|
||||||
|
calls.push({ method: 'addToIndex', id })
|
||||||
|
return super.addToIndex(id, entityOrMetadata, skipFlush, deferWrites, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFromIndex(id: string, metadata?: any, generation?: bigint): Promise<void> {
|
||||||
|
calls.push({ method: 'removeFromIndex', id })
|
||||||
|
return super.removeFromIndex(id, metadata, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate-remove+delegate-add internally via `super.*` (bypassing this
|
||||||
|
// class's own overrides) so the update-op path records ONLY
|
||||||
|
// 'updateIndex' for the id it touches — never the pair.
|
||||||
|
async updateIndex(id: string, before: any, after: any, generation?: bigint): Promise<void> {
|
||||||
|
calls.push({ method: 'updateIndex', id })
|
||||||
|
await super.removeFromIndex(id, before, generation)
|
||||||
|
await super.addToIndex(id, after, true, false, generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RecordingMetadataProvider(storage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A provider whose `capabilities` claims 'update-op' but never implements `updateIndex` — pin (d). */
|
||||||
|
function makeLyingMetadataFactory(): (storage: any) => MetadataIndexManager {
|
||||||
|
return (storage: any) => {
|
||||||
|
class LyingMetadataProvider extends MetadataIndexManager {
|
||||||
|
capabilities = new Set<string>(['update-op'])
|
||||||
|
// Deliberately no `updateIndex` override.
|
||||||
|
}
|
||||||
|
return new LyingMetadataProvider(storage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The graph-index counterpart of {@link makeRecordingMetadataFactory}. */
|
||||||
|
function makeRecordingGraphFactory(
|
||||||
|
calls: RecordedVerbCall[],
|
||||||
|
opts: { capable?: boolean } = {}
|
||||||
|
): (storage: any) => GraphAdjacencyIndex {
|
||||||
|
const capable = opts.capable !== false
|
||||||
|
return (storage: any) => {
|
||||||
|
class RecordingGraphProvider extends GraphAdjacencyIndex {
|
||||||
|
capabilities = capable ? new Set<string>(['update-op']) : undefined
|
||||||
|
|
||||||
|
async addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint, generation: bigint): Promise<bigint> {
|
||||||
|
calls.push({ method: 'addVerb', id: verb.id })
|
||||||
|
return super.addVerb(verb, sourceInt, targetInt, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeVerb(verbId: string, generation: bigint): Promise<void> {
|
||||||
|
calls.push({ method: 'removeVerb', id: verbId })
|
||||||
|
return super.removeVerb(verbId, generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVerb(id: string, beforeVerb: GraphVerb, afterVerb: GraphVerb, generation: bigint): Promise<void> {
|
||||||
|
calls.push({ method: 'updateVerb', id })
|
||||||
|
await super.removeVerb(id, generation)
|
||||||
|
// The JS index's addVerb ignores sourceInt/targetInt (it operates on
|
||||||
|
// the verb's string ids internally) — endpoints never change across
|
||||||
|
// an update, so no real resolution is needed here.
|
||||||
|
await super.addVerb(afterVerb, 0n, 0n, generation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RecordingGraphProvider(storage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const brains: Brainy[] = []
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function makeBrain(plugin: any): Promise<Brainy> {
|
||||||
|
const brain = new Brainy({
|
||||||
|
storage: { type: 'memory' },
|
||||||
|
requireSubtype: false,
|
||||||
|
silent: true,
|
||||||
|
plugins: []
|
||||||
|
})
|
||||||
|
brain.use(plugin)
|
||||||
|
await brain.init()
|
||||||
|
brains.push(brain)
|
||||||
|
return brain
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('(a) update() emission on a capable metadata provider', () => {
|
||||||
|
it('emits exactly one updateIndex call and zero removeFromIndex/addToIndex for that id; find() sees the new row, not the old', async () => {
|
||||||
|
const calls: RecordedCall[] = []
|
||||||
|
const brain = await makeBrain({
|
||||||
|
name: 'recording-metadata-a',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = await brain.add({ data: 'a', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
await brain.update({ id, metadata: { tag: 'new' } })
|
||||||
|
|
||||||
|
expect(calls.filter((c) => c.id === id)).toEqual([{ method: 'updateIndex', id }])
|
||||||
|
|
||||||
|
expect((await brain.find({ where: { tag: 'new' } })).some((r: any) => r.id === id)).toBe(true)
|
||||||
|
expect((await brain.find({ where: { tag: 'old' } })).some((r: any) => r.id === id)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('(b) transact([{ op: "update" }]) emission on a capable metadata provider', () => {
|
||||||
|
it('emits exactly one updateIndex call', async () => {
|
||||||
|
const calls: RecordedCall[] = []
|
||||||
|
const brain = await makeBrain({
|
||||||
|
name: 'recording-metadata-b',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = await brain.add({ data: 'b', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
await brain.transact([{ op: 'update', id, metadata: { tag: 'new' } }] as any)
|
||||||
|
|
||||||
|
expect(calls.filter((c) => c.id === id)).toEqual([{ method: 'updateIndex', id }])
|
||||||
|
expect((await brain.find({ where: { tag: 'new' } })).some((r: any) => r.id === id)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('(c) rollback symmetry', () => {
|
||||||
|
it('a batch rejected at PLAN time never touches the provider for the update id — the row keeps its old metadata', async () => {
|
||||||
|
const calls: RecordedCall[] = []
|
||||||
|
const brain = await makeBrain({
|
||||||
|
name: 'recording-metadata-c1',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = await brain.add({ data: 'c1', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
// planTxRelate rejects an unknown target BEFORE commitTransaction is
|
||||||
|
// ever called — nothing in the batch (including the earlier update)
|
||||||
|
// executes, so the provider is never invoked for `id`.
|
||||||
|
await expect(
|
||||||
|
brain.transact([
|
||||||
|
{ op: 'update', id, metadata: { tag: 'new' } },
|
||||||
|
{ op: 'relate', from: id, to: freshId(), type: VerbType.RelatedTo }
|
||||||
|
] as any)
|
||||||
|
).rejects.toBeInstanceOf(EntityNotFoundError)
|
||||||
|
|
||||||
|
expect(calls.filter((c) => c.id === id)).toEqual([])
|
||||||
|
expect((await brain.get(id))?.metadata?.tag).toBe('old')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a batch rejected DURING EXECUTE (a later op\'s index write fails) rolls the update back symmetrically: updateIndex(id, after, before)', async () => {
|
||||||
|
const calls: RecordedCall[] = []
|
||||||
|
const failId = freshId()
|
||||||
|
const brain = await makeBrain({
|
||||||
|
name: 'recording-metadata-c2',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls, { failAddFor: failId }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = await brain.add({ data: 'c2', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
brain.transact([
|
||||||
|
{ op: 'update', id, metadata: { tag: 'new' } },
|
||||||
|
{ op: 'add', id: failId, data: 'boom', type: NounType.Concept, vector: V() }
|
||||||
|
] as any)
|
||||||
|
).rejects.toThrow()
|
||||||
|
|
||||||
|
// Forward call, then the symmetric rollback (before/after swapped).
|
||||||
|
expect(calls.filter((c) => c.id === id).map((c) => c.method)).toEqual(['updateIndex', 'updateIndex'])
|
||||||
|
expect((await brain.get(id))?.metadata?.tag).toBe('old')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('(d) registration-time refusal', () => {
|
||||||
|
it('a provider whose capabilities claim update-op but lacks updateIndex is refused loudly, with the typed code', async () => {
|
||||||
|
const brain = new Brainy({
|
||||||
|
storage: { type: 'memory' },
|
||||||
|
requireSubtype: false,
|
||||||
|
silent: true,
|
||||||
|
plugins: []
|
||||||
|
})
|
||||||
|
brain.use({
|
||||||
|
name: 'lying-metadata-provider',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('metadataIndex', makeLyingMetadataFactory())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let caught: unknown
|
||||||
|
try {
|
||||||
|
await brain.init()
|
||||||
|
brains.push(brain)
|
||||||
|
} catch (err) {
|
||||||
|
caught = err
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(ProviderCapabilityMismatchError)
|
||||||
|
expect((caught as ProviderCapabilityMismatchError).type).toBe('PROVIDER_CAPABILITY_MISMATCH')
|
||||||
|
expect((caught as ProviderCapabilityMismatchError).family).toBe('metadata')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('(e) legacy pair path (no capabilities announced)', () => {
|
||||||
|
it('update() emits the remove-old/add-new pair, both recorded, same commit', async () => {
|
||||||
|
const calls: RecordedCall[] = []
|
||||||
|
const brain = await makeBrain({
|
||||||
|
name: 'recording-metadata-e',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('metadataIndex', makeRecordingMetadataFactory(calls, { capable: false }))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = await brain.add({ data: 'e', type: NounType.Concept, metadata: { tag: 'old' }, vector: V() })
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
await brain.update({ id, metadata: { tag: 'new' } })
|
||||||
|
|
||||||
|
expect(calls.filter((c) => c.id === id).map((c) => c.method)).toEqual(['removeFromIndex', 'addToIndex'])
|
||||||
|
|
||||||
|
// From a caller's view the row is never absent between the two legs —
|
||||||
|
// by the time update() resolves, the new metadata is the only truth.
|
||||||
|
expect((await brain.find({ where: { tag: 'new' } })).some((r: any) => r.id === id)).toBe(true)
|
||||||
|
expect((await brain.find({ where: { tag: 'old' } })).some((r: any) => r.id === id)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('(f) updateRelation() emission on a capable graph provider', () => {
|
||||||
|
it('a type change emits exactly one updateVerb call; the relation reads back with the new type', async () => {
|
||||||
|
const calls: RecordedVerbCall[] = []
|
||||||
|
const brain = await makeBrain({
|
||||||
|
name: 'recording-graph-f',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('graphIndex', makeRecordingGraphFactory(calls))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const a = await brain.add({ data: 'a', type: NounType.Person, vector: V() })
|
||||||
|
const b = await brain.add({ data: 'b', type: NounType.Person, vector: V() })
|
||||||
|
const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith })
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
await brain.updateRelation({ id: relId, type: VerbType.ReportsTo })
|
||||||
|
|
||||||
|
expect(calls.filter((c) => c.id === relId)).toEqual([{ method: 'updateVerb', id: relId }])
|
||||||
|
|
||||||
|
// Read back via the metadata record directly rather than related({ type })
|
||||||
|
// — a PRE-EXISTING, unrelated bug (confirmed present on the legacy pair
|
||||||
|
// path too, unmodified by this change) means the verb's CORE stored
|
||||||
|
// record (written once by relate()'s SaveVerbOperation) never gets a
|
||||||
|
// fresh SaveVerbOperation on a type change, so hydrateVerbWithMetadata's
|
||||||
|
// `{ ...coreVerb, metadata: custom }` merge keeps serving the OLD `.verb`
|
||||||
|
// to related()'s storage fast path regardless of which graph-index
|
||||||
|
// emission ran. Out of scope here (this task only concerns the
|
||||||
|
// metadata/graph INDEX provider emission); the metadata record itself —
|
||||||
|
// what updateRelation() actually owns — is the honest read.
|
||||||
|
const meta = await (brain as any).storage.getVerbMetadata(relId)
|
||||||
|
expect(meta?.verb).toBe(VerbType.ReportsTo)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('(g) transact([{ op: "updateRelation" }])', () => {
|
||||||
|
it('merges metadata and reads back', async () => {
|
||||||
|
const brain = await makeBrain({ name: 'plain-g1', activate: async () => true })
|
||||||
|
|
||||||
|
const a = await brain.add({ data: 'a', type: NounType.Person, vector: V() })
|
||||||
|
const b = await brain.add({ data: 'b', type: NounType.Person, vector: V() })
|
||||||
|
const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith, metadata: { x: 1 } })
|
||||||
|
|
||||||
|
await brain.transact([{ op: 'updateRelation', id: relId, metadata: { y: 2 } }] as any)
|
||||||
|
|
||||||
|
const after = await brain.related({ from: a, type: VerbType.WorksWith })
|
||||||
|
const rel = after.find((r) => r.id === relId)
|
||||||
|
expect(rel?.metadata).toEqual({ x: 1, y: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a type change through transact re-indexes (planTxUpdateRelation emits the graph leg, same as updateRelation())', async () => {
|
||||||
|
const calls: RecordedVerbCall[] = []
|
||||||
|
const brain = await makeBrain({
|
||||||
|
name: 'recording-graph-g2',
|
||||||
|
activate: async (ctx: any) => {
|
||||||
|
ctx.registerProvider('graphIndex', makeRecordingGraphFactory(calls))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const a = await brain.add({ data: 'a', type: NounType.Person, vector: V() })
|
||||||
|
const b = await brain.add({ data: 'b', type: NounType.Person, vector: V() })
|
||||||
|
const relId = await brain.relate({ from: a, to: b, type: VerbType.WorksWith })
|
||||||
|
calls.length = 0
|
||||||
|
|
||||||
|
await brain.transact([{ op: 'updateRelation', id: relId, type: VerbType.ReportsTo }] as any)
|
||||||
|
|
||||||
|
// planTxUpdateRelation took the SAME update-op branch as updateRelation()
|
||||||
|
// (see pin (f)) — one updateVerb call, not the pair.
|
||||||
|
expect(calls.filter((c) => c.id === relId)).toEqual([{ method: 'updateVerb', id: relId }])
|
||||||
|
|
||||||
|
const meta = await (brain as any).storage.getVerbMetadata(relId)
|
||||||
|
expect(meta?.verb).toBe(VerbType.ReportsTo)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an unknown id rejects the whole batch — other ops in it do not apply', async () => {
|
||||||
|
const brain = await makeBrain({ name: 'plain-g3', activate: async () => true })
|
||||||
|
|
||||||
|
const newId = freshId()
|
||||||
|
await expect(
|
||||||
|
brain.transact([
|
||||||
|
{ op: 'add', id: newId, data: 'never lands', type: NounType.Concept, vector: V() },
|
||||||
|
{ op: 'updateRelation', id: freshId(), subtype: 'ghost' }
|
||||||
|
] as any)
|
||||||
|
).rejects.toBeInstanceOf(RelationNotFoundError)
|
||||||
|
|
||||||
|
expect(await brain.get(newId)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue