feat(8.0): reserved-field contract — one canonical location, typed prevention, unified read/write

Brainy-owned field names (noun/verb, subtype, createdAt, updatedAt,
confidence, weight, service, data, createdBy, _rev) now have exactly one
home — top level — enforced by three layers driven from a single source of
truth, src/types/reservedFields.ts (RESERVED_ENTITY_FIELDS /
RESERVED_RELATION_FIELDS, exported):

1. Compile time — AddParams/UpdateParams/RelateParams/UpdateRelationParams
   metadata (and the transact() ops that extend them) reject a literal
   reserved key as a TypeScript error while keeping generic T ergonomics
   (typed bags, untyped brains, index-signature shapes, and a documented
   exemption for T-declared reserved keys). Pinned by @ts-expect-error
   type tests run under vitest typecheck mode on every unit run.

2. Write time — the 7.x update() remap is ported to 8.0 and extended to
   every write path: add/update/relate/updateRelation, their transact()
   mirrors, and db.with() overlays. User-settable fields lift to their
   dedicated param (top-level wins when both are supplied — closes the 7.x
   trap where update({metadata:{confidence}}) silently no-oped), and
   system-managed fields drop with a one-shot warning naming the right
   path. A remapped subtype satisfies subtype-pairing enforcement exactly
   like a top-level one.

3. Read time — every storage combine goes through one canonical hydration
   helper (hydrateNounWithMetadata / hydrateVerbWithMetadata over
   splitNoun/VerbMetadataRecord), so reserved fields surface ONLY top-level
   and entity/relation.metadata carry ONLY custom fields on live reads,
   batch reads, paginated listings, getRelations by source/target, streamed
   verbs, and historical asOf() materialization alike.

Read-path echoes found and fixed (previously the full stored record —
including the verb type key — leaked inside metadata): noun pagination,
verb pagination, getVerbsBySource/ByTarget (adjacency + shard fallback),
getVerbsBySourceBatch (which also dropped subtype/data), and the
filesystem verb stream. getRelations() results now surface
confidence/updatedAt top-level via verbsToRelations, updateRelation() no
longer erases service/createdBy, relate() persists its top-level
confidence/service params, and the dead convertHNSWVerbToGraphVerb echo
path is deleted. Import paths (CLI extract, deduplicator, coordinators,
neural import) write confidence through the dedicated param instead of the
bag. UpdateRelationParams is now exported from the package root.

Documented for consumers in docs/concepts/consistency-model.md ("Reserved
fields") and RELEASES.md. Regression tests ported from the 7.x fix and
extended to the full 8.0 contract (17 runtime tests + 41 type-level
assertions); full unit suite 1427/1427, db-mvcc integration 24/24.
This commit is contained in:
David Snelling 2026-06-11 13:12:50 -07:00
parent c44678390e
commit 970e08c466
18 changed files with 1688 additions and 452 deletions

View file

@ -100,6 +100,10 @@ import {
FillSubtypesResult
} from './types/brainy.types.js'
import { NounType, VerbType, TypeUtils } from './types/graphTypes.js'
import {
splitNounMetadataRecord,
splitVerbMetadataRecord
} from './types/reservedFields.js'
import { BrainyInterface } from './types/brainyInterface.js'
import type { IntegrationHub } from './integrations/core/IntegrationHub.js'
import { MigrationRunner } from './migration/MigrationRunner.js'
@ -1163,6 +1167,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Zero-config validation (static import for performance)
validateAddParams(params)
// Reserved fields arriving via the metadata bag (untyped callers — the
// compile-time guard stops TypeScript callers) are normalized to their
// canonical top-level location BEFORE any enforcement runs, so a
// remapped subtype participates in subtype-pairing enforcement and the
// indexed metadata bag carries only custom fields.
params = this.remapReservedAddMetadata(params)
// Tracked-field vocabulary enforcement (Layer 2). Walks both bags so a
// tracked field declared at top level (e.g. 'subtype') and one declared in
// metadata (e.g. 'status') both validate.
@ -1574,34 +1585,291 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Metadata-only entity (no vector loading)
// This is 76-81% faster for operations that don't need semantic similarity
// Extract standard fields, rest are custom metadata
// Same destructuring as baseStorage.getNoun() to ensure consistency
const { noun, subtype, createdAt, updatedAt, confidence, weight, service, data, createdBy, _rev, ...customMetadata } = metadata
// Canonical reserved/custom split — same single source of truth as every
// other read path (see src/types/reservedFields.ts).
const { reserved, custom } = splitNounMetadataRecord(metadata)
const entity: Entity<T> = {
id,
vector: [], // Stub vector (empty array - vectors not loaded for metadata-only)
type: noun as NounType || NounType.Thing,
subtype,
type: (reserved.noun as NounType) || NounType.Thing,
subtype: reserved.subtype as string | undefined,
// Standard fields from metadata
confidence,
weight,
createdAt: createdAt || Date.now(),
updatedAt: updatedAt || Date.now(),
service,
data,
createdBy,
confidence: reserved.confidence as number | undefined,
weight: reserved.weight as number | undefined,
createdAt: (reserved.createdAt as number) || Date.now(),
updatedAt: (reserved.updatedAt as number) || Date.now(),
service: reserved.service as string | undefined,
data: reserved.data,
createdBy: reserved.createdBy as Entity<T>['createdBy'],
// 7.31.0 — surface revision counter (defaults to 1 for pre-7.31.0 entities)
_rev: typeof _rev === 'number' ? _rev : 1,
_rev: typeof reserved._rev === 'number' ? reserved._rev : 1,
// Custom user fields (standard fields removed, only custom remain)
metadata: customMetadata as T
metadata: custom as T
}
return entity
}
/** One-shot registry for reserved-field drop warnings (per process, per method+field). */
private static warnedReservedFields = new Set<string>()
/**
* @description Warn once per process (per method+field) that a reserved
* field arrived inside a metadata bag and was dropped, naming the correct
* write path. Only dropped (system-managed) fields warn user-settable
* fields are remapped to their dedicated param and honored silently.
* @param method - The public write method the bag arrived through.
* @param field - The reserved field name that was dropped.
* @param rightPath - Human guidance naming the correct write path.
*/
private warnDroppedReservedField(method: string, field: string, rightPath: string): void {
const key = `${method}:${field}`
if (Brainy.warnedReservedFields.has(key)) return
Brainy.warnedReservedFields.add(key)
prodLog.warn(
`[brainy] ${method}(): '${field}' is a reserved field and cannot be set ` +
`through the metadata bag — use ${rightPath}. The value was ignored. ` +
`(This warning is shown once per field per process.)`
)
}
/**
* @description Emit the one-shot drop warnings for system-managed entity
* fields found in a metadata bag. Shared by the `add` and `update` remaps
* (live calls and their `transact()` mirrors).
* @param method - `'add'` or `'update'` (the contract is identical for the
* matching `transact()` operation).
* @param reserved - The reserved half of the split metadata bag.
*/
private warnDroppedReservedEntityFields(
method: 'add' | 'update',
reserved: Partial<Record<string, unknown>>
): void {
if (reserved.noun !== undefined) {
this.warnDroppedReservedField(method, 'noun', "the top-level 'type' param")
}
if (reserved.data !== undefined) {
this.warnDroppedReservedField(method, 'data', "the top-level 'data' param")
}
if (reserved.createdAt !== undefined) {
this.warnDroppedReservedField(
method,
'createdAt',
method === 'add'
? 'nothing — creation time is set automatically'
: 'nothing — creation time is immutable'
)
}
if (reserved.updatedAt !== undefined) {
this.warnDroppedReservedField(method, 'updatedAt', 'nothing — set automatically on every write')
}
if (reserved._rev !== undefined) {
this.warnDroppedReservedField(
method,
'_rev',
method === 'update'
? "the 'ifRev' param for optimistic concurrency"
: 'nothing — revisions are system-managed'
)
}
if (method === 'update') {
if (reserved.service !== undefined) {
this.warnDroppedReservedField(method, 'service', 'nothing — fixed at add() time')
}
if (reserved.createdBy !== undefined) {
this.warnDroppedReservedField(method, 'createdBy', 'nothing — fixed at add() time')
}
}
}
/**
* @description Normalize an `add()` params object with respect to
* Brainy-reserved fields arriving inside `metadata` (untyped callers only
* the compile-time guard on `AddParams.metadata` stops TypeScript callers).
* Fields with a dedicated `add()` param (`confidence`, `weight`, `subtype`,
* `service`, `createdBy`) are remapped to that param unless the caller also
* passed it explicitly (top-level wins); system-managed fields (`noun`,
* `data`, `createdAt`, `updatedAt`, `_rev`) are dropped with a one-shot
* warning naming the correct write path. The remapped `subtype` flows
* through subtype-pairing enforcement exactly like a top-level one.
* @param params - The caller's add params (not mutated).
* @returns Params with reserved fields normalized out of `metadata`.
*/
private remapReservedAddMetadata(params: AddParams<T>): AddParams<T> {
const bag = params.metadata as Record<string, unknown> | undefined
if (!bag || typeof bag !== 'object') return params
const { reserved, custom } = splitNounMetadataRecord(bag)
if (Object.keys(reserved).length === 0) return params
this.warnDroppedReservedEntityFields('add', reserved)
const createdBy = reserved.createdBy as { augmentation?: unknown; version?: unknown } | undefined
const createdByValid =
typeof createdBy === 'object' &&
createdBy !== null &&
typeof createdBy.augmentation === 'string' &&
typeof createdBy.version === 'string'
return {
...params,
metadata: custom as AddParams<T>['metadata'],
...(params.confidence === undefined &&
typeof reserved.confidence === 'number' && { confidence: reserved.confidence }),
...(params.weight === undefined &&
typeof reserved.weight === 'number' && { weight: reserved.weight }),
...(params.subtype === undefined &&
typeof reserved.subtype === 'string' && { subtype: reserved.subtype }),
...(params.service === undefined &&
typeof reserved.service === 'string' && { service: reserved.service }),
...(params.createdBy === undefined &&
createdByValid && { createdBy: createdBy as { augmentation: string; version: string } })
}
}
/**
* @description Normalize an `update()` params object with respect to
* Brainy-reserved fields arriving inside the metadata patch the `update()`
* mirror of {@link remapReservedAddMetadata}, closing the historical trap
* where `add({metadata:{confidence}})` lifted the field but
* `update({metadata:{confidence}})` silently dropped it (the patch value
* survived the merge and was then clobbered by the preserve-existing
* spread; a production consumer's confidence-evolution writes no-oped until
* read back). User-mutable fields (`confidence`, `weight`, `subtype`)
* remap to their dedicated param unless the caller also passed it
* (top-level wins); everything else (`noun`, `data`, `createdAt`,
* `updatedAt`, `service`, `createdBy`, `_rev`) is system-managed or fixed
* at `add()` time and is dropped with a one-shot warning.
* @param params - The caller's update params (not mutated).
* @returns Params with reserved fields normalized out of `metadata`.
*/
private remapReservedUpdateMetadata(params: UpdateParams<T>): UpdateParams<T> {
const bag = params.metadata as Record<string, unknown> | undefined
if (!bag || typeof bag !== 'object') return params
const { reserved, custom } = splitNounMetadataRecord(bag)
if (Object.keys(reserved).length === 0) return params
this.warnDroppedReservedEntityFields('update', reserved)
return {
...params,
metadata: custom as UpdateParams<T>['metadata'],
...(params.confidence === undefined &&
typeof reserved.confidence === 'number' && { confidence: reserved.confidence }),
...(params.weight === undefined &&
typeof reserved.weight === 'number' && { weight: reserved.weight }),
...(params.subtype === undefined &&
typeof reserved.subtype === 'string' && { subtype: reserved.subtype })
}
}
/**
* @description Emit the one-shot drop warnings for system-managed
* relationship fields found in a metadata bag. Shared by the `relate` and
* `updateRelation` remaps (live calls and the `transact()` relate mirror).
* @param method - `'relate'` or `'updateRelation'`.
* @param reserved - The reserved half of the split metadata bag.
*/
private warnDroppedReservedRelationFields(
method: 'relate' | 'updateRelation',
reserved: Partial<Record<string, unknown>>
): void {
if (reserved.verb !== undefined) {
this.warnDroppedReservedField(method, 'verb', "the top-level 'type' param")
}
if (reserved.data !== undefined) {
this.warnDroppedReservedField(method, 'data', "the top-level 'data' param")
}
if (reserved.createdAt !== undefined) {
this.warnDroppedReservedField(
method,
'createdAt',
method === 'relate'
? 'nothing — creation time is set automatically'
: 'nothing — creation time is immutable'
)
}
if (reserved.updatedAt !== undefined) {
this.warnDroppedReservedField(method, 'updatedAt', 'nothing — set automatically on every write')
}
if (reserved.createdBy !== undefined) {
this.warnDroppedReservedField(method, 'createdBy', 'nothing — system-managed')
}
if (reserved._rev !== undefined) {
this.warnDroppedReservedField(method, '_rev', 'nothing — system-managed')
}
if (method === 'updateRelation' && reserved.service !== undefined) {
this.warnDroppedReservedField(method, 'service', 'nothing — fixed at relate() time')
}
}
/**
* @description Normalize a `relate()` params object with respect to
* Brainy-reserved fields arriving inside `metadata` the relationship
* mirror of {@link remapReservedAddMetadata}. Fields with a dedicated
* `relate()` param (`confidence`, `weight`, `subtype`, `service`) remap to
* that param (top-level wins); system-managed fields (`verb`, `data`,
* `createdAt`, `updatedAt`, `createdBy`, `_rev`) are dropped with a
* one-shot warning.
* @param params - The caller's relate params (not mutated).
* @returns Params with reserved fields normalized out of `metadata`.
*/
private remapReservedRelateMetadata(params: RelateParams<T>): RelateParams<T> {
const bag = params.metadata as Record<string, unknown> | undefined
if (!bag || typeof bag !== 'object') return params
const { reserved, custom } = splitVerbMetadataRecord(bag)
if (Object.keys(reserved).length === 0) return params
this.warnDroppedReservedRelationFields('relate', reserved)
return {
...params,
metadata: custom as RelateParams<T>['metadata'],
...(params.confidence === undefined &&
typeof reserved.confidence === 'number' && { confidence: reserved.confidence }),
...(params.weight === undefined &&
typeof reserved.weight === 'number' && { weight: reserved.weight }),
...(params.subtype === undefined &&
typeof reserved.subtype === 'string' && { subtype: reserved.subtype }),
...(params.service === undefined &&
typeof reserved.service === 'string' && { service: reserved.service })
}
}
/**
* @description Normalize an `updateRelation()` params object with respect
* to Brainy-reserved fields arriving inside the metadata patch the
* relationship mirror of {@link remapReservedUpdateMetadata}. User-mutable
* fields (`confidence`, `weight`, `subtype`) remap to their dedicated
* param (top-level wins); everything else is dropped with a one-shot
* warning.
* @param params - The caller's update-relation params (not mutated).
* @returns Params with reserved fields normalized out of `metadata`.
*/
private remapReservedUpdateRelationMetadata(
params: UpdateRelationParams<T>
): UpdateRelationParams<T> {
const bag = params.metadata as Record<string, unknown> | undefined
if (!bag || typeof bag !== 'object') return params
const { reserved, custom } = splitVerbMetadataRecord(bag)
if (Object.keys(reserved).length === 0) return params
this.warnDroppedReservedRelationFields('updateRelation', reserved)
return {
...params,
metadata: custom as UpdateRelationParams<T>['metadata'],
...(params.confidence === undefined &&
typeof reserved.confidence === 'number' && { confidence: reserved.confidence }),
...(params.weight === undefined &&
typeof reserved.weight === 'number' && { weight: reserved.weight }),
...(params.subtype === undefined &&
typeof reserved.subtype === 'string' && { subtype: reserved.subtype })
}
}
/**
* Update an existing entity
*
@ -1659,6 +1927,16 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Zero-config validation (static import for performance)
validateUpdateParams(params)
// Reserved fields arriving via the metadata patch are remapped to their
// canonical top-level location, mirroring add()'s lift. Without this the
// patch value survived the merge but was then clobbered by the
// preserve-existing spreads below — a silent no-op consumers could only
// detect by reading values back. User-mutable fields (confidence,
// weight, subtype) remap unless the same field was also passed top-level
// (top-level wins); system-managed fields are dropped with a one-shot
// warning naming the right path.
params = this.remapReservedUpdateMetadata(params)
// Tracked-field vocabulary enforcement (Layer 2). Same as add() — the
// metadata bag carries fields registered via trackField(), and subtype is
// a tracked top-level candidate.
@ -1690,10 +1968,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
// ifRev (7.31.0) — optimistic concurrency. If caller supplied ifRev, the persisted
// _rev must match exactly. Pre-7.31.0 entities without _rev are treated as rev 1.
const currentRev = typeof (existing.metadata as any)?._rev === 'number'
? (existing.metadata as any)._rev
: (typeof (existing as any)._rev === 'number' ? (existing as any)._rev : 1)
// _rev must match exactly. `_rev` is reserved: every read path surfaces it ONLY
// top-level (entities without one are read as rev 1), so that is the one place
// to look.
const currentRev = typeof existing._rev === 'number' ? existing._rev : 1
if (typeof params.ifRev === 'number' && params.ifRev !== currentRev) {
throw new RevisionConflictError(params.id, params.ifRev, currentRev)
}
@ -1900,13 +2178,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Aggregation hook (outside transaction — derived data)
if (this._aggregationIndex && metadata) {
// Reconstruct entity-like object from stored metadata
const { noun, createdAt, updatedAt, confidence, weight, service, data, createdBy, ...customMetadata } = metadata
// Reconstruct entity-like object from stored metadata via the
// canonical reserved/custom split (the hand-rolled destructure here
// missed subtype/_rev, leaking them into the aggregation view).
const { reserved, custom } = splitNounMetadataRecord(metadata)
const entityForAgg = {
type: noun,
service,
data,
metadata: customMetadata
type: reserved.noun,
service: reserved.service,
data: reserved.data,
metadata: custom
}
this._aggregationIndex.onEntityDeleted(id, entityForAgg)
}
@ -2168,6 +2448,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Zero-config validation (static import for performance)
validateRelateParams(params)
// Reserved fields arriving via the metadata bag are normalized to their
// canonical top-level params before enforcement — mirror of add()'s lift.
params = this.remapReservedRelateMetadata(params)
// Subtype pairing enforcement (Layer 3 — 7.30.0). Per-type rules registered
// via brain.requireSubtype() compose with the brain-wide strict-mode flag.
// Metadata is passed so infrastructure edges (VFS containment) can bypass
@ -2225,8 +2509,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
verb: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
weight: params.weight ?? 1.0,
...(params.confidence !== undefined && { confidence: params.confidence }),
...(params.service !== undefined && { service: params.service }),
createdAt: Date.now(),
...((params as any).data !== undefined && { data: (params as any).data })
...(params.data !== undefined && { data: params.data })
}
// Save to storage (vector and metadata separately)
@ -2239,8 +2525,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
type: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
weight: params.weight ?? 1.0,
...(params.confidence !== undefined && { confidence: params.confidence }),
...(params.service !== undefined && { service: params.service }),
metadata: params.metadata,
data: (params as any).data,
data: params.data,
createdAt: Date.now()
}
@ -2392,6 +2680,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
validateUpdateRelationParams(params)
// Reserved fields arriving via the metadata patch are remapped to their
// canonical top-level params — mirror of update()'s normalization.
params = this.remapReservedUpdateRelationMetadata(params)
const existing = await this.storage.getVerb(params.id)
if (!existing) {
throw new Error(`Relation ${params.id} not found`)
@ -2428,6 +2720,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
...(params.confidence !== undefined
? { confidence: params.confidence }
: existingAny.confidence !== undefined && { confidence: existingAny.confidence }),
// service/createdBy are fixed at relate() time — always carried forward
// (omitting them here silently erased them on every updateRelation()).
...(existingAny.service !== undefined && { service: existingAny.service }),
...(existingAny.createdBy !== undefined && { createdBy: existingAny.createdBy }),
createdAt: existingAny.createdAt,
updatedAt: Date.now(),
...(params.data !== undefined
@ -5030,31 +5326,25 @@ export class Brainy<T = any> implements BrainyInterface<T> {
)
}
const {
verb,
subtype,
createdAt,
updatedAt,
confidence,
weight,
service,
data,
...customMetadata
} = record.metadata as Record<string, any>
// Canonical reserved/custom split — same single source of truth as the
// live verb read paths (see src/types/reservedFields.ts).
const { reserved, custom } = splitVerbMetadataRecord(
record.metadata as Record<string, unknown>
)
return {
id,
from: core.sourceId,
to: core.targetId,
type: (verb ?? core.verb) as VerbType,
...(subtype !== undefined && { subtype: subtype as string }),
weight: (weight as number) ?? 1.0,
data,
metadata: customMetadata as T,
service: service as string,
createdAt: typeof createdAt === 'number' ? createdAt : Date.now(),
...(typeof updatedAt === 'number' && { updatedAt }),
...(typeof confidence === 'number' && { confidence })
type: (reserved.verb ?? core.verb) as VerbType,
...(reserved.subtype !== undefined && { subtype: reserved.subtype as string }),
weight: (reserved.weight as number) ?? 1.0,
data: reserved.data,
metadata: custom as T,
service: reserved.service as string,
createdAt: typeof reserved.createdAt === 'number' ? reserved.createdAt : Date.now(),
...(typeof reserved.updatedAt === 'number' && { updatedAt: reserved.updatedAt }),
...(typeof reserved.confidence === 'number' && { confidence: reserved.confidence })
}
}
@ -5318,8 +5608,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
state: TxPlanState,
plan: PlannedTransact
): Promise<string> {
const { op: _discriminator, ...params } = op
validateAddParams(params as AddParams<T>)
const { op: _discriminator, ...rawParams } = op
validateAddParams(rawParams as AddParams<T>)
// Same reserved-field normalization as add() — the metadata bag is
// cleaned BEFORE enforcement so a remapped subtype participates in
// subtype-pairing enforcement and only custom fields reach the index.
const params = this.remapReservedAddMetadata(rawParams as AddParams<T>)
this.enforceTrackedFieldValues(params.metadata as Record<string, unknown> | undefined, 'metadata')
this.enforceTrackedFieldValues({ subtype: params.subtype } as Record<string, unknown>, 'top-level')
this.enforceSubtypeOnAdd('add', params.type, params.subtype, params.metadata)
@ -5404,8 +5698,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
state: TxPlanState,
plan: PlannedTransact
): Promise<string> {
const { op: _discriminator, ...params } = op
validateUpdateParams(params as UpdateParams<T>)
const { op: _discriminator, ...rawParams } = op
validateUpdateParams(rawParams as UpdateParams<T>)
// Same reserved-field normalization as update() — user-mutable fields
// remap to their dedicated param (top-level wins), system-managed fields
// drop with a one-shot warning.
const params = this.remapReservedUpdateMetadata(rawParams as UpdateParams<T>)
this.enforceTrackedFieldValues(params.metadata as Record<string, unknown> | undefined, 'metadata')
if (params.subtype !== undefined) {
this.enforceTrackedFieldValues({ subtype: params.subtype } as Record<string, unknown>, 'top-level')
@ -5424,13 +5722,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
// ifRev CAS — identical resolution to update(); a conflict rejects the
// WHOLE batch before anything is staged or applied.
const currentRev =
typeof (existing.metadata as any)?._rev === 'number'
? (existing.metadata as any)._rev
: typeof existing._rev === 'number'
? existing._rev
: 1
// WHOLE batch before anything is staged or applied. `_rev` is reserved:
// every read path surfaces it ONLY top-level.
const currentRev = typeof existing._rev === 'number' ? existing._rev : 1
if (typeof params.ifRev === 'number' && params.ifRev !== currentRev) {
throw new RevisionConflictError(params.id, params.ifRev, currentRev)
}
@ -5601,8 +5895,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
plan.touchedNouns.push(id)
if (metadata) {
const { noun: nounType, createdAt, updatedAt, confidence, weight, service, data, createdBy, ...customMetadata } = metadata
const entityForAgg = { type: nounType, service, data, metadata: customMetadata }
// Canonical reserved/custom split — mirror of delete()'s aggregation hook.
const { reserved, custom } = splitNounMetadataRecord(metadata)
const entityForAgg = {
type: reserved.noun,
service: reserved.service,
data: reserved.data,
metadata: custom
}
plan.postCommit.push(() => {
if (this._aggregationIndex) {
this._aggregationIndex.onEntityDeleted(id, entityForAgg)
@ -5626,8 +5926,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
state: TxPlanState,
plan: PlannedTransact
): Promise<string> {
const { op: _discriminator, ...params } = op
validateRelateParams(params as RelateParams<T>)
const { op: _discriminator, ...rawParams } = op
validateRelateParams(rawParams as RelateParams<T>)
// Same reserved-field normalization as relate().
const params = this.remapReservedRelateMetadata(rawParams as RelateParams<T>)
this.enforceSubtypeOnRelate('relate', params.type, params.subtype, params.metadata)
const fromEntity = await this.planGetEntity(state, params.from)
@ -5677,6 +5979,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
verb: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
weight: params.weight ?? 1.0,
...(params.confidence !== undefined && { confidence: params.confidence }),
...(params.service !== undefined && { service: params.service }),
createdAt: now,
...(params.data !== undefined && { data: params.data })
}
@ -5689,6 +5993,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
type: params.type,
...(params.subtype !== undefined && { subtype: params.subtype }),
weight: params.weight ?? 1.0,
...(params.confidence !== undefined && { confidence: params.confidence }),
...(params.service !== undefined && { service: params.service }),
metadata: params.metadata,
data: params.data,
createdAt: now
@ -8238,7 +8544,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
if (!readBoth && from.kind === 'metadata') {
delete nextMeta[from.field]
}
update.metadata = nextMeta as T
update.metadata = nextMeta as UpdateParams<T>['metadata']
update.merge = false
} else {
// data path — only meaningful when data is an object
@ -8256,7 +8562,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const base = (entity.metadata as unknown as Record<string, unknown>) ?? {}
const nextMeta: Record<string, unknown> = { ...base }
delete nextMeta[from.field]
update.metadata = nextMeta as T
update.metadata = nextMeta as UpdateParams<T>['metadata']
update.merge = false
} else if (from.kind === 'data' && to.kind !== 'data') {
const base = (entity.data && typeof entity.data === 'object') ? { ...(entity.data as Record<string, unknown>) } : null
@ -9560,7 +9866,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
/**
* Convert verbs to relations (read from top-level)
* Convert storage-shape verbs to public `Relation`s. The storage layer has
* already done the canonical reserved/custom split (every combine goes
* through `hydrateVerbWithMetadata` see src/types/reservedFields.ts), so
* this is a pure top-level field mapping: `metadata` carries ONLY custom
* fields, and every reserved field (`subtype`, `weight`, `confidence`,
* timestamps, `service`, `data`) surfaces at top level.
*/
private verbsToRelations(verbs: GraphVerb[]): Relation<T>[] {
return verbs.map((v) => {
@ -9572,10 +9883,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
type: (v.verb || v.type) as VerbType,
...(va.subtype !== undefined && { subtype: va.subtype as string }),
weight: v.weight ?? 1.0,
...(typeof va.confidence === 'number' && { confidence: va.confidence as number }),
data: v.data,
metadata: v.metadata,
service: v.service as string,
createdAt: typeof v.createdAt === 'number' ? v.createdAt : Date.now()
createdAt: typeof v.createdAt === 'number' ? v.createdAt : Date.now(),
...(typeof va.updatedAt === 'number' && { updatedAt: va.updatedAt as number })
}
})
}