feat(8.0): reserved-field enforcement — reservedFieldPolicy defaults to throw
An untyped (JS) caller that smuggles a Brainy-reserved field (confidence, weight, subtype, visibility, service, createdBy, noun/verb, data, createdAt, updatedAt, _rev) inside a write-path metadata bag previously got a silent remap-or-drop — a class of bug where confidence-evolution writes no-oped for weeks before being caught on read-back. 8.0 closes this with no silent failures. - New BrainyConfig.reservedFieldPolicy: 'throw' | 'warn' | 'remap' (default 'throw'). 'throw' rejects the write naming every offending key + its correct write path; 'warn' remaps with a one-shot per-key warning; 'remap' is the legacy silent path. - Central enforceReservedPolicy gate wired into all four remap methods (add, update, relate, updateRelation) so live calls AND their transact()/with() mirrors honor it. Single-source reservedWritePath guidance shared by throw+warn. - 'warn' now warns for EVERY reserved key (closes the gap where only system-managed fields warned). Dead warnDropped* helpers removed. - Import pipeline migrated to route reserved values (confidence/weight/subtype) through dedicated params and strip reserved keys from extractor/customMetadata bags via the canonical split*MetadataRecord helpers — imports no longer trip the default throw. - Tests: new reservedFieldPolicy matrix (throw/warn/remap across every write path + transact); remap-correctness suite reframed as opt-in 'remap'; shared test-factory no longer emits reserved keys in custom metadata.
This commit is contained in:
parent
ae3fe82fd9
commit
54c7c39669
12 changed files with 605 additions and 190 deletions
303
src/brainy.ts
303
src/brainy.ts
|
|
@ -1708,95 +1708,162 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return entity
|
||||
}
|
||||
|
||||
/** One-shot registry for reserved-field drop warnings (per process, per method+field). */
|
||||
/** One-shot registry for reserved-field 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.
|
||||
* @description Resolve the human-readable "correct write path" guidance for a
|
||||
* reserved field on a given write method. Single source of truth shared by the
|
||||
* `'throw'` (Error message) and `'warn'` (one-shot warning) paths so the two
|
||||
* never drift. The trio `confidence` / `weight` / `subtype` and the
|
||||
* add()/relate()-time fields `service` / `createdBy` / `visibility` map to a
|
||||
* dedicated param; everything else is system-managed.
|
||||
* @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.
|
||||
* @param field - The reserved field name found in the metadata bag.
|
||||
* @returns Guidance naming the correct way to set the field.
|
||||
*/
|
||||
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.)`
|
||||
)
|
||||
private reservedWritePath(
|
||||
method: 'add' | 'update' | 'relate' | 'updateRelation',
|
||||
field: string
|
||||
): string {
|
||||
const typeParam = "the top-level 'type' param"
|
||||
switch (field) {
|
||||
case 'noun':
|
||||
case 'verb':
|
||||
return typeParam
|
||||
case 'data':
|
||||
return "the top-level 'data' param"
|
||||
case 'confidence':
|
||||
return "the 'confidence' param"
|
||||
case 'weight':
|
||||
return "the 'weight' param"
|
||||
case 'subtype':
|
||||
return "the 'subtype' param"
|
||||
case 'visibility':
|
||||
return "the 'visibility' param ('public' | 'internal')"
|
||||
case 'service':
|
||||
return method === 'add'
|
||||
? "the 'service' param of add()"
|
||||
: method === 'relate'
|
||||
? "the 'service' param of relate()"
|
||||
: 'nothing — service is fixed at create time'
|
||||
case 'createdBy':
|
||||
return method === 'add'
|
||||
? "the 'createdBy' param of add()"
|
||||
: 'nothing — createdBy is system-managed'
|
||||
case 'createdAt':
|
||||
return 'nothing — creation time is set automatically'
|
||||
case 'updatedAt':
|
||||
return 'nothing — set automatically on every write'
|
||||
case '_rev':
|
||||
return method === 'update'
|
||||
? "the 'ifRev' param for optimistic concurrency"
|
||||
: 'nothing — revisions are system-managed'
|
||||
default:
|
||||
return 'a dedicated top-level param'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* @description Enforce {@link BrainyConfig.reservedFieldPolicy} for reserved
|
||||
* fields found inside a metadata bag. Called by every write-path remap once
|
||||
* the bag has been split and at least one reserved key is present.
|
||||
*
|
||||
* - `'throw'` (default): throw a clear Error naming every offending key and
|
||||
* its correct write path. The caller never reaches the remap.
|
||||
* - `'warn'`: emit a ONE-SHOT (per method+field, per process) warning for
|
||||
* EVERY reserved key found — both the user-mutable fields that are about to
|
||||
* be remapped and the system-managed fields that are about to be dropped —
|
||||
* then fall through to the legacy remap.
|
||||
* - `'remap'`: silent legacy remap, no warning.
|
||||
*
|
||||
* @param method - The public write method the bag arrived through.
|
||||
* @param reserved - The reserved half of the split metadata bag (non-empty).
|
||||
* @param reservedListName - `'RESERVED_ENTITY_FIELDS'` or
|
||||
* `'RESERVED_RELATION_FIELDS'` — named in the thrown Error for discoverability.
|
||||
* @returns `true` when the caller should proceed with the legacy remap
|
||||
* (`'warn'` / `'remap'`); `'throw'` never returns (it throws first).
|
||||
* @throws {Error} When the policy is `'throw'` and any reserved key is present.
|
||||
*/
|
||||
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'
|
||||
private enforceReservedPolicy(
|
||||
method: 'add' | 'update' | 'relate' | 'updateRelation',
|
||||
reserved: Partial<Record<string, unknown>>,
|
||||
reservedListName: 'RESERVED_ENTITY_FIELDS' | 'RESERVED_RELATION_FIELDS'
|
||||
): boolean {
|
||||
const policy = this.config.reservedFieldPolicy ?? 'throw'
|
||||
const keys = Object.keys(reserved)
|
||||
if (keys.length === 0) return true
|
||||
|
||||
if (policy === 'throw') {
|
||||
const detail = keys
|
||||
.map((k) => {
|
||||
const path = this.reservedWritePath(method, k)
|
||||
// System-managed fields resolve to a "nothing — …" sentinel; phrase
|
||||
// those as "is system-managed" rather than "pass it as the nothing".
|
||||
return path.startsWith('nothing')
|
||||
? `metadata.${k} is a reserved field (${path.replace(/^nothing\s*—\s*/, '')}) and cannot be set through ${method}()`
|
||||
: `metadata.${k} is a reserved field — pass it as ${path} to ${method}()`
|
||||
})
|
||||
.join('; ')
|
||||
throw new Error(
|
||||
`${detail} (reserved: see ${reservedListName}). ` +
|
||||
`Set reservedFieldPolicy:'remap' to opt into legacy remapping, ` +
|
||||
`or reservedFieldPolicy:'warn' to remap with a warning.`
|
||||
)
|
||||
}
|
||||
if (reserved.updatedAt !== undefined) {
|
||||
this.warnDroppedReservedField(method, 'updatedAt', 'nothing — set automatically on every write')
|
||||
|
||||
if (policy === 'warn') {
|
||||
// One-shot warning for EVERY reserved key (today only system-managed ones
|
||||
// warn — this closes that gap so user-mutable remaps are visible too).
|
||||
for (const k of keys) {
|
||||
this.warnReservedRemapped(method, k, this.reservedWritePath(method, k))
|
||||
}
|
||||
if (reserved._rev !== undefined) {
|
||||
this.warnDroppedReservedField(
|
||||
method,
|
||||
'_rev',
|
||||
method === 'update'
|
||||
? "the 'ifRev' param for optimistic concurrency"
|
||||
: 'nothing — revisions are system-managed'
|
||||
}
|
||||
|
||||
// 'warn' and 'remap' both fall through to the legacy remap.
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @description One-shot (per method+field, per process) warning that a
|
||||
* reserved field arrived inside a metadata bag under the `'warn'` policy. The
|
||||
* wording is neutral on "remapped vs dropped" — `reservedWritePath()` already
|
||||
* tells the caller where the value goes (a dedicated param, or "nothing").
|
||||
* @param method - The public write method the bag arrived through.
|
||||
* @param field - The reserved field name found in the bag.
|
||||
* @param rightPath - Guidance naming the correct write path.
|
||||
*/
|
||||
private warnReservedRemapped(method: string, field: string, rightPath: string): void {
|
||||
const key = `${method}:${field}`
|
||||
if (Brainy.warnedReservedFields.has(key)) return
|
||||
Brainy.warnedReservedFields.add(key)
|
||||
// System-managed fields resolve to a "nothing — …" sentinel; phrase the
|
||||
// guidance so it reads cleanly in both the remapped and dropped cases.
|
||||
const guidance = rightPath.startsWith('nothing')
|
||||
? `it is ${rightPath.replace(/^nothing\s*—\s*/, '')} and was dropped`
|
||||
: `set it via ${rightPath} instead`
|
||||
prodLog.warn(
|
||||
`[brainy] ${method}(): '${field}' is a reserved field and was found inside the ` +
|
||||
`metadata bag — ${guidance}. (Legacy remap applied because ` +
|
||||
`reservedFieldPolicy is 'warn'. This warning is shown once per field per process.)`
|
||||
)
|
||||
}
|
||||
// 'system' visibility is Brainy-only — a user bag cannot set it. (A 'public'/'internal'
|
||||
// value IS user-settable and is silently remapped to the top-level param below.)
|
||||
if (reserved.visibility === 'system') {
|
||||
this.warnDroppedReservedField(method, 'visibility', "the 'visibility' param ('public' | 'internal')")
|
||||
}
|
||||
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
|
||||
* Governed by {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`):
|
||||
* `'throw'` rejects the write naming the offending key(s); `'warn'`/`'remap'`
|
||||
* fall through to the legacy remap, where fields with a dedicated `add()`
|
||||
* param (`confidence`, `weight`, `subtype`, `visibility`, `service`,
|
||||
* `createdBy`) are remapped to that param unless the caller also passed it
|
||||
* explicitly (top-level wins) and system-managed fields (`noun`, `data`,
|
||||
* `createdAt`, `updatedAt`, `_rev`) are dropped. A 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`.
|
||||
* @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key.
|
||||
*/
|
||||
private remapReservedAddMetadata(params: AddParams<T>): AddParams<T> {
|
||||
const bag = params.metadata as Record<string, unknown> | undefined
|
||||
|
|
@ -1804,7 +1871,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const { reserved, custom } = splitNounMetadataRecord(bag)
|
||||
if (Object.keys(reserved).length === 0) return params
|
||||
|
||||
this.warnDroppedReservedEntityFields('add', reserved)
|
||||
// Policy gate: 'throw' (default) throws here; 'warn' warns once per key then
|
||||
// remaps; 'remap' silently remaps. (Throw never returns.)
|
||||
this.enforceReservedPolicy('add', reserved, 'RESERVED_ENTITY_FIELDS')
|
||||
|
||||
const createdBy = reserved.createdBy as { augmentation?: unknown; version?: unknown } | undefined
|
||||
const createdByValid =
|
||||
|
|
@ -1841,13 +1910,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
* `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.
|
||||
* read back). Governed by {@link BrainyConfig.reservedFieldPolicy} (default
|
||||
* `'throw'`): `'throw'` rejects the write; `'warn'`/`'remap'` remap
|
||||
* user-mutable fields (`confidence`, `weight`, `subtype`) to their dedicated
|
||||
* param unless the caller also passed it (top-level wins) and drop everything
|
||||
* else (`noun`, `data`, `createdAt`, `updatedAt`, `service`, `createdBy`,
|
||||
* `_rev`) as system-managed or fixed at `add()` time.
|
||||
* @param params - The caller's update params (not mutated).
|
||||
* @returns Params with reserved fields normalized out of `metadata`.
|
||||
* @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key.
|
||||
*/
|
||||
private remapReservedUpdateMetadata(params: UpdateParams<T>): UpdateParams<T> {
|
||||
const bag = params.metadata as Record<string, unknown> | undefined
|
||||
|
|
@ -1855,7 +1926,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const { reserved, custom } = splitNounMetadataRecord(bag)
|
||||
if (Object.keys(reserved).length === 0) return params
|
||||
|
||||
this.warnDroppedReservedEntityFields('update', reserved)
|
||||
// Policy gate: 'throw' (default) throws; 'warn' warns once per key then
|
||||
// remaps; 'remap' silently remaps.
|
||||
this.enforceReservedPolicy('update', reserved, 'RESERVED_ENTITY_FIELDS')
|
||||
|
||||
return {
|
||||
...params,
|
||||
|
|
@ -1869,61 +1942,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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')
|
||||
}
|
||||
// 'system' visibility is Brainy-only — a user bag cannot set it. (A 'public'/'internal'
|
||||
// value IS user-settable and is silently remapped to the top-level param below.)
|
||||
if (reserved.visibility === 'system') {
|
||||
this.warnDroppedReservedField(method, 'visibility', "the 'visibility' param ('public' | 'internal')")
|
||||
}
|
||||
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.
|
||||
* mirror of {@link remapReservedAddMetadata}. Governed by
|
||||
* {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`): `'throw'`
|
||||
* rejects the write; `'warn'`/`'remap'` remap fields with a dedicated
|
||||
* `relate()` param (`confidence`, `weight`, `subtype`, `visibility`,
|
||||
* `service`) to that param (top-level wins) and drop system-managed fields
|
||||
* (`verb`, `data`, `createdAt`, `updatedAt`, `createdBy`, `_rev`).
|
||||
* @param params - The caller's relate params (not mutated).
|
||||
* @returns Params with reserved fields normalized out of `metadata`.
|
||||
* @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key.
|
||||
*/
|
||||
private remapReservedRelateMetadata(params: RelateParams<T>): RelateParams<T> {
|
||||
const bag = params.metadata as Record<string, unknown> | undefined
|
||||
|
|
@ -1931,7 +1961,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const { reserved, custom } = splitVerbMetadataRecord(bag)
|
||||
if (Object.keys(reserved).length === 0) return params
|
||||
|
||||
this.warnDroppedReservedRelationFields('relate', reserved)
|
||||
// Policy gate: 'throw' (default) throws; 'warn' warns once per key then
|
||||
// remaps; 'remap' silently remaps.
|
||||
this.enforceReservedPolicy('relate', reserved, 'RESERVED_RELATION_FIELDS')
|
||||
|
||||
return {
|
||||
...params,
|
||||
|
|
@ -1954,12 +1986,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
/**
|
||||
* @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.
|
||||
* relationship mirror of {@link remapReservedUpdateMetadata}. Governed by
|
||||
* {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`): `'throw'`
|
||||
* rejects the write; `'warn'`/`'remap'` remap user-mutable fields
|
||||
* (`confidence`, `weight`, `subtype`, `visibility`) to their dedicated param
|
||||
* (top-level wins) and drop everything else.
|
||||
* @param params - The caller's update-relation params (not mutated).
|
||||
* @returns Params with reserved fields normalized out of `metadata`.
|
||||
* @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key.
|
||||
*/
|
||||
private remapReservedUpdateRelationMetadata(
|
||||
params: UpdateRelationParams<T>
|
||||
|
|
@ -1969,7 +2003,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
const { reserved, custom } = splitVerbMetadataRecord(bag)
|
||||
if (Object.keys(reserved).length === 0) return params
|
||||
|
||||
this.warnDroppedReservedRelationFields('updateRelation', reserved)
|
||||
// Policy gate: 'throw' (default) throws; 'warn' warns once per key then
|
||||
// remaps; 'remap' silently remaps.
|
||||
this.enforceReservedPolicy('updateRelation', reserved, 'RESERVED_RELATION_FIELDS')
|
||||
|
||||
return {
|
||||
...params,
|
||||
|
|
@ -11229,7 +11265,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
requireSubtype: config?.requireSubtype ?? true,
|
||||
// Multi-process safety
|
||||
mode: config?.mode ?? 'writer',
|
||||
force: config?.force ?? false
|
||||
force: config?.force ?? false,
|
||||
// Reserved-field-in-metadata-bag policy (8.0 — no silent failures).
|
||||
// Default 'throw': an untyped caller that smuggles a reserved key past
|
||||
// the compile guard gets a loud Error naming the correct write path.
|
||||
// 'warn' = remap + one-shot warning per key; 'remap' = legacy silent remap.
|
||||
reservedFieldPolicy: config?.reservedFieldPolicy ?? 'throw'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { SmartYAMLImporter } from '../importers/SmartYAMLImporter.js'
|
|||
import { SmartDOCXImporter } from '../importers/SmartDOCXImporter.js'
|
||||
import { VFSStructureGenerator } from '../importers/VFSStructureGenerator.js'
|
||||
import { NounType, VerbType } from '../types/graphTypes.js'
|
||||
import { splitNounMetadataRecord, splitVerbMetadataRecord } from '../types/reservedFields.js'
|
||||
import { v4 as uuidv4 } from '../universal/uuid.js'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
|
@ -834,7 +835,11 @@ export class ImportCoordinator {
|
|||
type: 'media',
|
||||
description: '',
|
||||
confidence: 1.0,
|
||||
metadata: { subtype: 'image' }
|
||||
// `subtype` is a reserved field — keep it top-level on the extractor
|
||||
// entity (read into the `subtype` param at add() time), never inside
|
||||
// the metadata bag that gets spread into add({ metadata }).
|
||||
subtype: 'image',
|
||||
metadata: {}
|
||||
},
|
||||
relatedEntities: [],
|
||||
relationships: []
|
||||
|
|
@ -843,7 +848,8 @@ export class ImportCoordinator {
|
|||
id: imageId,
|
||||
name: imageName,
|
||||
type: 'media',
|
||||
metadata: { subtype: 'image' }
|
||||
subtype: 'image',
|
||||
metadata: {}
|
||||
}],
|
||||
relationships: [],
|
||||
metadata: {},
|
||||
|
|
@ -862,6 +868,38 @@ export class ImportCoordinator {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Brainy-reserved entity keys out of an extractor-supplied metadata bag.
|
||||
*
|
||||
* Extractors (and consumer `customMetadata`) can carry reserved keys
|
||||
* (`confidence`, `subtype`, `weight`, …) inside `metadata`. Brainy 8.0's
|
||||
* default `reservedFieldPolicy` is `'throw'`, so spreading such a bag into
|
||||
* `add({ metadata })` would reject the whole import. The import pipeline owns
|
||||
* the correct write path: user-mutable reserved values are passed as dedicated
|
||||
* `AddParams` params (see the call sites), so here we simply drop the reserved
|
||||
* half of the bag and keep only the custom fields that belong in `metadata`.
|
||||
*
|
||||
* @param bag - The extractor/consumer metadata bag (may be undefined).
|
||||
* @returns The custom-only metadata (reserved keys removed).
|
||||
*/
|
||||
private stripReservedFromBag(bag: Record<string, any> | undefined | null): Record<string, any> {
|
||||
if (!bag || typeof bag !== 'object') return {}
|
||||
return splitNounMetadataRecord(bag).custom
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship mirror of {@link stripReservedFromBag} — strips reserved verb
|
||||
* keys (`verb`, `confidence`, `weight`, `subtype`, …) out of an edge metadata
|
||||
* bag so it carries only custom fields. Reserved values that have a dedicated
|
||||
* `RelateParams` param are passed there by the call site instead.
|
||||
* @param bag - The extractor/consumer edge metadata bag (may be undefined).
|
||||
* @returns The custom-only edge metadata (reserved keys removed).
|
||||
*/
|
||||
private stripReservedFromRelationBag(bag: Record<string, any> | undefined | null): Record<string, any> {
|
||||
if (!bag || typeof bag !== 'object') return {}
|
||||
return splitVerbMetadataRecord(bag).custom
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entities and relationships in knowledge graph
|
||||
* Added sourceInfo parameter for document entity creation
|
||||
|
|
@ -977,7 +1015,7 @@ export class ImportCoordinator {
|
|||
importedAt: trackingContext.importedAt,
|
||||
importFormat: trackingContext.importFormat,
|
||||
importSource: trackingContext.importSource,
|
||||
...trackingContext.customMetadata
|
||||
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1005,10 +1043,14 @@ export class ImportCoordinator {
|
|||
data: entity.description || entity.name,
|
||||
type: entity.type,
|
||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
name: entity.name,
|
||||
// `confidence` is a reserved field — pass it as the dedicated param,
|
||||
// never inside the metadata bag (8.0 reservedFieldPolicy defaults to 'throw').
|
||||
confidence: entity.confidence,
|
||||
metadata: {
|
||||
// Extractor/consumer bags may smuggle reserved keys — strip them so
|
||||
// the bag carries only custom fields.
|
||||
...this.stripReservedFromBag(entity.metadata),
|
||||
name: entity.name,
|
||||
vfsPath: vfsFile?.path,
|
||||
importedFrom: 'import-coordinator',
|
||||
imports: [importSource],
|
||||
|
|
@ -1020,7 +1062,7 @@ export class ImportCoordinator {
|
|||
importSource: trackingContext.importSource,
|
||||
sourceRow: row.rowNumber,
|
||||
sourceSheet: row.sheet,
|
||||
...trackingContext.customMetadata
|
||||
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1101,7 +1143,7 @@ export class ImportCoordinator {
|
|||
importIds: [trackingContext.importId],
|
||||
projectId: trackingContext.projectId,
|
||||
importFormat: trackingContext.importFormat,
|
||||
...trackingContext.customMetadata
|
||||
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1132,10 +1174,12 @@ export class ImportCoordinator {
|
|||
data: entity.description || entity.name,
|
||||
type: entity.type,
|
||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
||||
metadata: {
|
||||
...entity.metadata,
|
||||
name: entity.name,
|
||||
// `confidence` is a reserved field — dedicated param, not metadata.
|
||||
confidence: entity.confidence,
|
||||
metadata: {
|
||||
// Strip any reserved keys an extractor smuggled into the bag.
|
||||
...this.stripReservedFromBag(entity.metadata),
|
||||
name: entity.name,
|
||||
vfsPath: vfsFile?.path,
|
||||
importedFrom: 'import-coordinator',
|
||||
// Import tracking metadata
|
||||
|
|
@ -1148,7 +1192,7 @@ export class ImportCoordinator {
|
|||
importSource: trackingContext.importSource,
|
||||
sourceRow: row.rowNumber,
|
||||
sourceSheet: row.sheet,
|
||||
...trackingContext.customMetadata
|
||||
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1188,7 +1232,7 @@ export class ImportCoordinator {
|
|||
importIds: [trackingContext.importId],
|
||||
projectId: trackingContext.projectId,
|
||||
importFormat: trackingContext.importFormat,
|
||||
...trackingContext.customMetadata
|
||||
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1243,7 +1287,7 @@ export class ImportCoordinator {
|
|||
projectId: trackingContext.projectId,
|
||||
importedAt: trackingContext.importedAt,
|
||||
importFormat: trackingContext.importFormat,
|
||||
...trackingContext.customMetadata
|
||||
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1273,7 +1317,7 @@ export class ImportCoordinator {
|
|||
projectId: trackingContext.projectId,
|
||||
importedAt: trackingContext.importedAt,
|
||||
importFormat: trackingContext.importFormat,
|
||||
...trackingContext.customMetadata
|
||||
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -1371,8 +1415,12 @@ export class ImportCoordinator {
|
|||
from: rel.from,
|
||||
to: rel.to,
|
||||
type: verbType, // Enhanced type
|
||||
// confidence/weight are reserved — carry them as dedicated params,
|
||||
// never inside the bag (they were collected top-level on each rel).
|
||||
...(typeof (rel as any).confidence === 'number' && { confidence: (rel as any).confidence }),
|
||||
...(typeof (rel as any).weight === 'number' && { weight: (rel as any).weight }),
|
||||
metadata: {
|
||||
...(rel.metadata || {}),
|
||||
...this.stripReservedFromRelationBag(rel.metadata),
|
||||
relationshipType: 'semantic', // Distinguish from VFS/provenance
|
||||
inferredType: verbType !== rel.type, // Track if type was enhanced
|
||||
originalType: rel.type
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { Brainy } from '../brainy.js'
|
||||
import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js'
|
||||
import { NounType, VerbType } from '../types/graphTypes.js'
|
||||
import { splitNounMetadataRecord } from '../types/reservedFields.js'
|
||||
import { SmartExcelImporter, SmartExcelOptions, SmartExcelResult } from './SmartExcelImporter.js'
|
||||
import { SmartPDFImporter, SmartPDFOptions, SmartPDFResult } from './SmartPDFImporter.js'
|
||||
import { SmartCSVImporter, SmartCSVOptions, SmartCSVResult } from './SmartCSVImporter.js'
|
||||
|
|
@ -201,7 +202,9 @@ export class SmartImportOrchestrator {
|
|||
.subtype ?? options.defaultSubtype ?? 'imported',
|
||||
confidence: extracted.entity.confidence, // reserved field — dedicated param, not metadata
|
||||
metadata: {
|
||||
...extracted.entity.metadata,
|
||||
// Strip reserved keys an extractor may have smuggled into the bag
|
||||
// (8.0 reservedFieldPolicy defaults to 'throw').
|
||||
...splitNounMetadataRecord(extracted.entity.metadata).custom,
|
||||
name: extracted.entity.name,
|
||||
importedFrom: 'smart-import'
|
||||
}
|
||||
|
|
@ -245,7 +248,7 @@ export class SmartImportOrchestrator {
|
|||
}
|
||||
|
||||
// Collect all relationship parameters
|
||||
const relationshipParams: Array<{from: string; to: string; type: VerbType; subtype?: string; metadata?: any}> = []
|
||||
const relationshipParams: Array<{from: string; to: string; type: VerbType; subtype?: string; confidence?: number; metadata?: any}> = []
|
||||
|
||||
for (const extracted of result.extraction.rows) {
|
||||
for (const rel of extracted.relationships) {
|
||||
|
|
@ -288,8 +291,8 @@ export class SmartImportOrchestrator {
|
|||
subtype:
|
||||
(rel as typeof rel & { subtype?: string }).subtype ??
|
||||
options.defaultSubtype ?? 'imported',
|
||||
confidence: rel.confidence, // reserved field — dedicated param, not metadata
|
||||
metadata: {
|
||||
confidence: rel.confidence,
|
||||
evidence: rel.evidence
|
||||
}
|
||||
})
|
||||
|
|
@ -615,7 +618,7 @@ export class SmartImportOrchestrator {
|
|||
(extracted.entity as typeof extracted.entity & { subtype?: string })
|
||||
.subtype ?? options.defaultSubtype ?? 'imported',
|
||||
confidence: extracted.entity.confidence, // reserved field — dedicated param, not metadata
|
||||
metadata: { ...extracted.entity.metadata, name: extracted.entity.name, importedFrom: 'smart-import' }
|
||||
metadata: { ...splitNounMetadataRecord(extracted.entity.metadata).custom, name: extracted.entity.name, importedFrom: 'smart-import' }
|
||||
})
|
||||
result.entityIds.push(entityId)
|
||||
result.stats.entitiesCreated++
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { Brainy } from '../brainy.js'
|
||||
import { NounType, VerbType } from '../types/graphTypes.js'
|
||||
import { splitNounMetadataRecord, splitVerbMetadataRecord } from '../types/reservedFields.js'
|
||||
import * as fs from '../universal/fs.js'
|
||||
import * as path from '../universal/path.js'
|
||||
// @ts-ignore
|
||||
|
|
@ -802,9 +803,12 @@ export class NeuralImport {
|
|||
data: this.extractMainText(entity.originalData),
|
||||
type: entity.nounType as NounType,
|
||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'extracted',
|
||||
metadata: {
|
||||
...entity.originalData,
|
||||
// `confidence` is a reserved field — dedicated param, not metadata
|
||||
// (8.0 reservedFieldPolicy defaults to 'throw').
|
||||
confidence: entity.confidence,
|
||||
metadata: {
|
||||
// Strip any reserved keys the source data smuggled into the bag.
|
||||
...splitNounMetadataRecord(entity.originalData).custom,
|
||||
id: entity.suggestedId
|
||||
}
|
||||
})
|
||||
|
|
@ -821,7 +825,8 @@ export class NeuralImport {
|
|||
confidence: relationship.confidence, // reserved field — dedicated param, not metadata
|
||||
metadata: {
|
||||
context: relationship.context,
|
||||
...relationship.metadata
|
||||
// Strip any reserved keys smuggled into the edge metadata bag.
|
||||
...splitVerbMetadataRecord(relationship.metadata).custom
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1496,6 +1496,33 @@ export interface BrainyConfig {
|
|||
* (PID liveness + heartbeat) cannot prove it. Logs a warning regardless.
|
||||
*/
|
||||
force?: boolean
|
||||
|
||||
/**
|
||||
* How write paths react when an untyped (JavaScript) caller smuggles a
|
||||
* Brainy-reserved field (`RESERVED_ENTITY_FIELDS` / `RESERVED_RELATION_FIELDS`
|
||||
* — `confidence`, `weight`, `subtype`, `visibility`, `service`, `createdBy`,
|
||||
* `noun`/`verb`, `data`, `createdAt`, `updatedAt`, `_rev`) **inside the
|
||||
* `metadata` bag** of `add()` / `update()` / `relate()` / `updateRelation()`
|
||||
* (and their `transact()` / `with()` mirrors). TypeScript callers can't write
|
||||
* these shapes at all — the compile-time guard on the metadata param types
|
||||
* (`NoReservedEntityKeys` / `NoReservedRelationKeys`) rejects a literal
|
||||
* reserved key — so this policy only governs untyped callers that slip one
|
||||
* past the compiler.
|
||||
*
|
||||
* - `'throw'` (**default, 8.0**): a reserved key in the bag throws a clear
|
||||
* `Error` naming the offending key(s) and the correct write path. No silent
|
||||
* remap, no data loss, no surprise. This is the 8.0 "no silent failures"
|
||||
* contract.
|
||||
* - `'warn'`: legacy remapping with a loud, one-shot (per key, per process)
|
||||
* warning for EVERY reserved key found — user-mutable fields are remapped to
|
||||
* their dedicated top-level param (top-level wins when both are supplied),
|
||||
* system-managed fields are dropped. Use while migrating untyped call sites.
|
||||
* - `'remap'`: the pre-8.0 silent remapping, no warning. Last-resort
|
||||
* compatibility hatch for code that intentionally relies on the bag path.
|
||||
*
|
||||
* @default 'throw'
|
||||
*/
|
||||
reservedFieldPolicy?: 'throw' | 'warn' | 'remap'
|
||||
}
|
||||
|
||||
// ============= Neural API Types =============
|
||||
|
|
|
|||
|
|
@ -201,7 +201,8 @@ describe('Performance Benchmarks - SLA Validation', () => {
|
|||
const start = performance.now()
|
||||
await brainy.update({
|
||||
id,
|
||||
metadata: { version: 2, updatedAt: Date.now() }
|
||||
// `updatedAt` is system-managed — Brainy sets it on every write.
|
||||
metadata: { version: 2 }
|
||||
})
|
||||
const latency = performance.now() - start
|
||||
benchmark.recordOperation(latency)
|
||||
|
|
|
|||
|
|
@ -39,17 +39,21 @@ export function generateTestVector(dimension = 384): Vector {
|
|||
return vector.map(val => val / magnitude)
|
||||
}
|
||||
|
||||
// Generate realistic metadata
|
||||
// Generate realistic CUSTOM metadata.
|
||||
//
|
||||
// NOTE: this deliberately contains only custom (consumer-owned) fields. Reserved
|
||||
// Brainy fields (`createdAt`, `updatedAt`, `confidence`, `weight`, `subtype`,
|
||||
// `visibility`, `service`, `createdBy`, `data`, `_rev`) must NOT live in the
|
||||
// metadata bag — Brainy 8.0's default `reservedFieldPolicy: 'throw'` rejects any
|
||||
// reserved key passed through `metadata`. (Reserved values belong in their
|
||||
// dedicated top-level params, set by the add/relate factories below.)
|
||||
export function generateTestMetadata(overrides: any = {}): any {
|
||||
return {
|
||||
name: `Test Item ${generateTestId()}`,
|
||||
description: 'Test description',
|
||||
tags: ['test', 'automated'],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
source: 'test-factory',
|
||||
confidence: 0.95,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,10 +61,12 @@ describe('Batch Import with Immediate Relations (v5.7.3 Fix)', () => {
|
|||
const entityParams = Array(372).fill(null).map((_, i) => ({
|
||||
data: `Extracted Entity ${i}`,
|
||||
type: NounType.Thing,
|
||||
// `confidence` is a reserved field — pass it as the dedicated param, not
|
||||
// inside the metadata bag (8.0 reservedFieldPolicy defaults to 'throw').
|
||||
confidence: 0.9,
|
||||
metadata: {
|
||||
extractedFrom: 'TfT~Sapient Species.pdf',
|
||||
entityNumber: i,
|
||||
confidence: 0.9
|
||||
entityNumber: i
|
||||
}
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -61,7 +61,9 @@ describe('Remaining APIs Comprehensive Test', () => {
|
|||
await brain.updateMany({
|
||||
items: ids.successful.map(id => ({
|
||||
id,
|
||||
metadata: { status: 'published', updatedAt: Date.now() }
|
||||
// `updatedAt` is system-managed (set automatically on every write) —
|
||||
// only the custom `status` field belongs in the metadata bag.
|
||||
metadata: { status: 'published' }
|
||||
}))
|
||||
})
|
||||
|
||||
|
|
|
|||
251
tests/unit/brainy/reserved-field-policy.test.ts
Normal file
251
tests/unit/brainy/reserved-field-policy.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/reserved-field-policy
|
||||
* @description The 8.0 `reservedFieldPolicy` matrix — what happens when an
|
||||
* untyped (JavaScript) caller smuggles a Brainy-reserved field INSIDE the
|
||||
* `metadata` bag of a write call, past the compile-time guard.
|
||||
*
|
||||
* 8.0 is a clean break with no silent failures. The decided contract:
|
||||
* - `'throw'` (DEFAULT): a reserved key in the bag throws a clear Error naming
|
||||
* the offending key(s) and the correct write path. No remap, no data loss.
|
||||
* - `'warn'`: legacy remap PLUS a one-shot (per method+field, per process)
|
||||
* warning for EVERY reserved key found.
|
||||
* - `'remap'`: the pre-8.0 silent remap, no warning.
|
||||
*
|
||||
* The deep correctness of the remap itself (top-level precedence, system-managed
|
||||
* drops, transact()/with() mirrors, read-side splitting) lives in
|
||||
* tests/unit/brainy/update-reserved-metadata-remap.test.ts (which now runs under
|
||||
* `reservedFieldPolicy: 'remap'`). This file pins the POLICY SELECTION and the
|
||||
* throw/warn behaviors.
|
||||
*
|
||||
* Compile-time callers can't write these shapes at all (see
|
||||
* tests/unit/types/reserved-metadata-keys.test-d.ts); the `as object` widenings
|
||||
* below simulate untyped callers.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { Brainy } from '../../../src/index.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
import { prodLog } from '../../../src/utils/logger.js'
|
||||
|
||||
describe('reservedFieldPolicy', () => {
|
||||
describe("default policy is 'throw'", () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
// No reservedFieldPolicy override → resolves to 'throw'.
|
||||
brain = new Brainy(createTestConfig())
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
})
|
||||
|
||||
it('add() throws naming the offending key and the correct write path', async () => {
|
||||
await expect(
|
||||
brain.add({
|
||||
type: NounType.Concept,
|
||||
subtype: 'general',
|
||||
data: 'x',
|
||||
metadata: { confidence: 0.8 } as object
|
||||
})
|
||||
).rejects.toThrow(/metadata\.confidence is a reserved field/)
|
||||
|
||||
// The error names the right param and the reserved list for discoverability.
|
||||
await expect(
|
||||
brain.add({
|
||||
type: NounType.Concept,
|
||||
subtype: 'general',
|
||||
data: 'x',
|
||||
metadata: { confidence: 0.8 } as object
|
||||
})
|
||||
).rejects.toThrow(/'confidence' param.*RESERVED_ENTITY_FIELDS/s)
|
||||
})
|
||||
|
||||
it('add() lists EVERY offending key when several are present', async () => {
|
||||
const err = await brain
|
||||
.add({
|
||||
type: NounType.Person,
|
||||
data: 'multi',
|
||||
metadata: { confidence: 0.5, weight: 0.6, subtype: 'employee' } as object
|
||||
})
|
||||
.catch((e) => e as Error)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toMatch(/confidence/)
|
||||
expect(err.message).toMatch(/weight/)
|
||||
expect(err.message).toMatch(/subtype/)
|
||||
})
|
||||
|
||||
it('update() throws on a reserved key in the patch', async () => {
|
||||
const id = await brain.add({ type: NounType.Concept, subtype: 'general', data: 'y' })
|
||||
await expect(
|
||||
brain.update({ id, metadata: { confidence: 0.3 } as object })
|
||||
).rejects.toThrow(/metadata\.confidence is a reserved field/)
|
||||
})
|
||||
|
||||
it('relate() throws on a reserved key in the bag', async () => {
|
||||
const a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
|
||||
const b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
|
||||
await expect(
|
||||
brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.RelatedTo,
|
||||
subtype: 'colleague',
|
||||
metadata: { confidence: 0.4 } as object
|
||||
})
|
||||
).rejects.toThrow(/metadata\.confidence is a reserved field.*RESERVED_RELATION_FIELDS/s)
|
||||
})
|
||||
|
||||
it('updateRelation() throws on a reserved key in the patch', async () => {
|
||||
const a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
|
||||
const b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
|
||||
const relId = await brain.relate({
|
||||
from: a,
|
||||
to: b,
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct'
|
||||
})
|
||||
await expect(
|
||||
brain.updateRelation({ id: relId, metadata: { weight: 0.2 } as object })
|
||||
).rejects.toThrow(/metadata\.weight is a reserved field/)
|
||||
})
|
||||
|
||||
it('transact() add op throws on a reserved key in the bag', async () => {
|
||||
await expect(
|
||||
brain.transact([
|
||||
{
|
||||
op: 'add',
|
||||
type: NounType.Concept,
|
||||
subtype: 'general',
|
||||
data: 'tx',
|
||||
metadata: { confidence: 0.7 } as object
|
||||
}
|
||||
])
|
||||
).rejects.toThrow(/metadata\.confidence is a reserved field/)
|
||||
})
|
||||
|
||||
it('a custom (non-reserved) key in the bag does NOT throw', async () => {
|
||||
const id = await brain.add({
|
||||
type: NounType.Concept,
|
||||
subtype: 'general',
|
||||
data: 'ok',
|
||||
metadata: { status: 'draft', rating: 4 }
|
||||
})
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.metadata).toEqual({ status: 'draft', rating: 4 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("'remap' policy remaps silently (no warning)", () => {
|
||||
let brain: Brainy
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(async () => {
|
||||
warnSpy = vi.spyOn(prodLog, 'warn').mockImplementation(() => {})
|
||||
brain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' }))
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('lifts user-mutable reserved fields to top-level without warning', async () => {
|
||||
const id = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: 'remap lift',
|
||||
metadata: { confidence: 0.8, weight: 0.6, subtype: 'employee', dept: 'eng' } as object
|
||||
})
|
||||
const entity = await brain.get(id)
|
||||
expect(entity?.confidence).toBe(0.8)
|
||||
expect(entity?.weight).toBe(0.6)
|
||||
expect(entity?.subtype).toBe('employee')
|
||||
expect(entity?.metadata).toEqual({ dept: 'eng' })
|
||||
// 'remap' is silent about reserved fields (unrelated storage logs may fire,
|
||||
// so assert specifically that no reserved-field warning was emitted).
|
||||
const reservedWarned = warnSpy.mock.calls.some((c) =>
|
||||
String(c[0]).includes('reserved field')
|
||||
)
|
||||
expect(reservedWarned).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves _originalId on natural-key ids through the remap path', async () => {
|
||||
// A speculative view applies the same normalization and maps a natural-key
|
||||
// id to a stable UUID, preserving the caller's original string.
|
||||
const base = await brain.now()
|
||||
const speculative = await base.with([
|
||||
{
|
||||
op: 'add',
|
||||
id: 'remap-spec-entity',
|
||||
type: NounType.Concept,
|
||||
subtype: 'general',
|
||||
data: 'spec',
|
||||
metadata: { confidence: 0.65, custom: 'spec' } as object
|
||||
}
|
||||
])
|
||||
const entity = await speculative.get('remap-spec-entity')
|
||||
expect(entity?.confidence).toBe(0.65)
|
||||
expect(entity?.metadata).toEqual({ custom: 'spec', _originalId: 'remap-spec-entity' })
|
||||
await speculative.release()
|
||||
await base.release()
|
||||
})
|
||||
})
|
||||
|
||||
describe("'warn' policy remaps AND warns once per key", () => {
|
||||
let brain: Brainy
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(async () => {
|
||||
warnSpy = vi.spyOn(prodLog, 'warn').mockImplementation(() => {})
|
||||
brain = new Brainy(createTestConfig({ reservedFieldPolicy: 'warn' }))
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await brain.close()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('remaps the value (same as remap) and emits a warning naming the field', async () => {
|
||||
// Use a method+field combo unique to this test so the per-process one-shot
|
||||
// registry has not already consumed it.
|
||||
const id = await brain.add({
|
||||
type: NounType.Person,
|
||||
data: 'warn lift',
|
||||
// weight is user-mutable → remapped; this is the only 'warn'-policy
|
||||
// add({ weight }) in the suite, so the one-shot warning fires here.
|
||||
metadata: { weight: 0.42, dept: 'eng' } as object
|
||||
})
|
||||
const entity = await brain.get(id)
|
||||
// Value is honored (remap still happens under 'warn').
|
||||
expect(entity?.weight).toBe(0.42)
|
||||
expect(entity?.metadata).toEqual({ dept: 'eng' })
|
||||
// And a warning was emitted naming the reserved field.
|
||||
expect(warnSpy).toHaveBeenCalled()
|
||||
const warned = warnSpy.mock.calls.some((c) =>
|
||||
String(c[0]).includes("'weight'")
|
||||
)
|
||||
expect(warned).toBe(true)
|
||||
})
|
||||
|
||||
it('warns for system-managed keys too (closes the historical gap)', async () => {
|
||||
// Pre-8.0 only system-managed fields warned; 'warn' warns for every key.
|
||||
// 'createdBy' (system-managed on update) is unique to this test.
|
||||
const id = await brain.add({ type: NounType.Concept, subtype: 'general', data: 'sys' })
|
||||
warnSpy.mockClear()
|
||||
await brain.update({ id, metadata: { createdBy: 'nope', keep: 'me' } as object })
|
||||
const entity = await brain.get(id)
|
||||
// System-managed key dropped; custom field merged.
|
||||
expect((entity?.metadata as Record<string, unknown>)?.createdBy).toBeUndefined()
|
||||
expect((entity?.metadata as Record<string, unknown>)?.keep).toBe('me')
|
||||
// A warning was emitted for the dropped system-managed key.
|
||||
const warned = warnSpy.mock.calls.some((c) =>
|
||||
String(c[0]).includes("'createdBy'")
|
||||
)
|
||||
expect(warned).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -10,13 +10,19 @@
|
|||
* consumer's confidence-evolution writes no-oped for weeks before being
|
||||
* caught by reading values back.
|
||||
*
|
||||
* 8.0 contract under test (every write path, entities AND relationships):
|
||||
* These tests pin the LEGACY REMAP behavior, which in 8.0 is opt-in via
|
||||
* `reservedFieldPolicy: 'remap'` (the default is `'throw'` — see the policy
|
||||
* matrix in tests/unit/brainy/reserved-field-policy.test.ts). The brain in
|
||||
* every test below is constructed with `reservedFieldPolicy: 'remap'` so these
|
||||
* deep correctness assertions about the remap path stay exercised.
|
||||
*
|
||||
* Remap contract under test (every write path, entities AND relationships):
|
||||
* - user-mutable reserved fields (`confidence`, `weight`, `subtype` — plus
|
||||
* `service`/`createdBy` at add()/relate() time) remap from the metadata
|
||||
* bag to their dedicated top-level param, with top-level winning when both
|
||||
* are present;
|
||||
* - system-managed reserved fields (`createdAt`, `_rev`, `noun`/`verb`,
|
||||
* `data`, …) are dropped from the bag (one-shot warning);
|
||||
* `data`, …) are dropped from the bag;
|
||||
* - the same normalization applies to `transact()` operations and `with()`
|
||||
* speculative views;
|
||||
* - reads NEVER echo a reserved field inside `metadata`.
|
||||
|
|
@ -32,11 +38,12 @@ import { Brainy } from '../../../src/index.js'
|
|||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
|
||||
describe('reserved-field metadata remap (8.0 contract)', () => {
|
||||
describe('reserved-field metadata remap (8.0 legacy remap path)', () => {
|
||||
let brain: Brainy
|
||||
|
||||
beforeEach(async () => {
|
||||
brain = new Brainy(createTestConfig())
|
||||
// The remap path is opt-in in 8.0 (default policy is 'throw').
|
||||
brain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' }))
|
||||
await brain.init()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -198,36 +198,60 @@ describe('visibility (8.0 reserved field)', () => {
|
|||
expect(entity?.visibility).toBeUndefined()
|
||||
})
|
||||
|
||||
it('an untyped caller passing visibility inside metadata is normalized (lifted to top-level)', async () => {
|
||||
it('an untyped caller passing visibility inside metadata is normalized under reservedFieldPolicy:"remap" (lifted to top-level)', async () => {
|
||||
// Simulate a JavaScript caller smuggling the reserved key past the compile-time guard.
|
||||
const id = await brain.add({
|
||||
// The legacy remap behavior is now opt-in (8.0 default is 'throw').
|
||||
const remapBrain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' }))
|
||||
await remapBrain.init()
|
||||
try {
|
||||
const id = await remapBrain.add({
|
||||
type: NounType.Concept,
|
||||
data: 'y',
|
||||
metadata: { visibility: 'internal', tag: 't' } as object
|
||||
})
|
||||
const entity = await brain.get(id)
|
||||
const entity = await remapBrain.get(id)
|
||||
// Lifted to the top-level field…
|
||||
expect(entity?.visibility).toBe('internal')
|
||||
// …and stripped from the metadata bag.
|
||||
expect((entity?.metadata as Record<string, unknown>)?.visibility).toBeUndefined()
|
||||
expect((entity?.metadata as Record<string, unknown>)?.tag).toBe('t')
|
||||
// It is excluded from the default count, exactly like a top-level internal write.
|
||||
expect(await brain.getNounCount()).toBe(0)
|
||||
expect(await remapBrain.getNounCount()).toBe(0)
|
||||
} finally {
|
||||
await remapBrain.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('a "system" value smuggled through metadata is dropped, not honored', async () => {
|
||||
it('a "system" value smuggled through metadata is dropped under reservedFieldPolicy:"remap", not honored', async () => {
|
||||
// 'system' is Brainy-only; an untyped caller must not be able to set it.
|
||||
const id = await brain.add({
|
||||
const remapBrain = new Brainy(createTestConfig({ reservedFieldPolicy: 'remap' }))
|
||||
await remapBrain.init()
|
||||
try {
|
||||
const id = await remapBrain.add({
|
||||
type: NounType.Concept,
|
||||
data: 'z',
|
||||
metadata: { visibility: 'system' } as object
|
||||
})
|
||||
const entity = await brain.get(id)
|
||||
const entity = await remapBrain.get(id)
|
||||
// The smuggled 'system' was dropped → entity stays public (counted, visible).
|
||||
expect(entity?.visibility).toBeUndefined()
|
||||
expect(await brain.getNounCount()).toBe(1)
|
||||
const found = await brain.find({ type: NounType.Concept, limit: 10 })
|
||||
expect(await remapBrain.getNounCount()).toBe(1)
|
||||
const found = await remapBrain.find({ type: NounType.Concept, limit: 10 })
|
||||
expect(found.map((r) => r.id)).toContain(id)
|
||||
} finally {
|
||||
await remapBrain.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('an untyped caller passing visibility inside metadata throws under the default policy', async () => {
|
||||
// 8.0 default: no silent remap — a reserved key in the bag is a loud error.
|
||||
await expect(
|
||||
brain.add({
|
||||
type: NounType.Concept,
|
||||
data: 'throws',
|
||||
metadata: { visibility: 'internal', tag: 't' } as object
|
||||
})
|
||||
).rejects.toThrow(/visibility.*reserved field/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue