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
|
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>()
|
private static warnedReservedFields = new Set<string>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Warn once per process (per method+field) that a reserved
|
* @description Resolve the human-readable "correct write path" guidance for a
|
||||||
* field arrived inside a metadata bag and was dropped, naming the correct
|
* reserved field on a given write method. Single source of truth shared by the
|
||||||
* write path. Only dropped (system-managed) fields warn — user-settable
|
* `'throw'` (Error message) and `'warn'` (one-shot warning) paths so the two
|
||||||
* fields are remapped to their dedicated param and honored silently.
|
* 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 method - The public write method the bag arrived through.
|
||||||
* @param field - The reserved field name that was dropped.
|
* @param field - The reserved field name found in the metadata bag.
|
||||||
* @param rightPath - Human guidance naming the correct write path.
|
* @returns Guidance naming the correct way to set the field.
|
||||||
*/
|
*/
|
||||||
private warnDroppedReservedField(method: string, field: string, rightPath: string): void {
|
private reservedWritePath(
|
||||||
const key = `${method}:${field}`
|
method: 'add' | 'update' | 'relate' | 'updateRelation',
|
||||||
if (Brainy.warnedReservedFields.has(key)) return
|
field: string
|
||||||
Brainy.warnedReservedFields.add(key)
|
): string {
|
||||||
prodLog.warn(
|
const typeParam = "the top-level 'type' param"
|
||||||
`[brainy] ${method}(): '${field}' is a reserved field and cannot be set ` +
|
switch (field) {
|
||||||
`through the metadata bag — use ${rightPath}. The value was ignored. ` +
|
case 'noun':
|
||||||
`(This warning is shown once per field per process.)`
|
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
|
* @description Enforce {@link BrainyConfig.reservedFieldPolicy} for reserved
|
||||||
* fields found in a metadata bag. Shared by the `add` and `update` remaps
|
* fields found inside a metadata bag. Called by every write-path remap once
|
||||||
* (live calls and their `transact()` mirrors).
|
* the bag has been split and at least one reserved key is present.
|
||||||
* @param method - `'add'` or `'update'` (the contract is identical for the
|
*
|
||||||
* matching `transact()` operation).
|
* - `'throw'` (default): throw a clear Error naming every offending key and
|
||||||
* @param reserved - The reserved half of the split metadata bag.
|
* 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(
|
private enforceReservedPolicy(
|
||||||
method: 'add' | 'update',
|
method: 'add' | 'update' | 'relate' | 'updateRelation',
|
||||||
reserved: Partial<Record<string, unknown>>
|
reserved: Partial<Record<string, unknown>>,
|
||||||
): void {
|
reservedListName: 'RESERVED_ENTITY_FIELDS' | 'RESERVED_RELATION_FIELDS'
|
||||||
if (reserved.noun !== undefined) {
|
): boolean {
|
||||||
this.warnDroppedReservedField(method, 'noun', "the top-level 'type' param")
|
const policy = this.config.reservedFieldPolicy ?? 'throw'
|
||||||
}
|
const keys = Object.keys(reserved)
|
||||||
if (reserved.data !== undefined) {
|
if (keys.length === 0) return true
|
||||||
this.warnDroppedReservedField(method, 'data', "the top-level 'data' param")
|
|
||||||
}
|
if (policy === 'throw') {
|
||||||
if (reserved.createdAt !== undefined) {
|
const detail = keys
|
||||||
this.warnDroppedReservedField(
|
.map((k) => {
|
||||||
method,
|
const path = this.reservedWritePath(method, k)
|
||||||
'createdAt',
|
// System-managed fields resolve to a "nothing — …" sentinel; phrase
|
||||||
method === 'add'
|
// those as "is system-managed" rather than "pass it as the nothing".
|
||||||
? 'nothing — creation time is set automatically'
|
return path.startsWith('nothing')
|
||||||
: 'nothing — creation time is immutable'
|
? `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,
|
// 'warn' and 'remap' both fall through to the legacy remap.
|
||||||
'_rev',
|
return true
|
||||||
method === 'update'
|
}
|
||||||
? "the 'ifRev' param for optimistic concurrency"
|
|
||||||
: 'nothing — revisions are system-managed'
|
/**
|
||||||
|
* @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
|
* @description Normalize an `add()` params object with respect to
|
||||||
* Brainy-reserved fields arriving inside `metadata` (untyped callers only —
|
* Brainy-reserved fields arriving inside `metadata` (untyped callers only —
|
||||||
* the compile-time guard on `AddParams.metadata` stops TypeScript callers).
|
* the compile-time guard on `AddParams.metadata` stops TypeScript callers).
|
||||||
* Fields with a dedicated `add()` param (`confidence`, `weight`, `subtype`,
|
* Governed by {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`):
|
||||||
* `service`, `createdBy`) are remapped to that param unless the caller also
|
* `'throw'` rejects the write naming the offending key(s); `'warn'`/`'remap'`
|
||||||
* passed it explicitly (top-level wins); system-managed fields (`noun`,
|
* fall through to the legacy remap, where fields with a dedicated `add()`
|
||||||
* `data`, `createdAt`, `updatedAt`, `_rev`) are dropped with a one-shot
|
* param (`confidence`, `weight`, `subtype`, `visibility`, `service`,
|
||||||
* warning naming the correct write path. The remapped `subtype` flows
|
* `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.
|
* through subtype-pairing enforcement exactly like a top-level one.
|
||||||
* @param params - The caller's add params (not mutated).
|
* @param params - The caller's add params (not mutated).
|
||||||
* @returns Params with reserved fields normalized out of `metadata`.
|
* @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> {
|
private remapReservedAddMetadata(params: AddParams<T>): AddParams<T> {
|
||||||
const bag = params.metadata as Record<string, unknown> | undefined
|
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)
|
const { reserved, custom } = splitNounMetadataRecord(bag)
|
||||||
if (Object.keys(reserved).length === 0) return params
|
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 createdBy = reserved.createdBy as { augmentation?: unknown; version?: unknown } | undefined
|
||||||
const createdByValid =
|
const createdByValid =
|
||||||
|
|
@ -1841,13 +1910,15 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
* `update({metadata:{confidence}})` silently dropped it (the patch value
|
* `update({metadata:{confidence}})` silently dropped it (the patch value
|
||||||
* survived the merge and was then clobbered by the preserve-existing
|
* survived the merge and was then clobbered by the preserve-existing
|
||||||
* spread; a production consumer's confidence-evolution writes no-oped until
|
* spread; a production consumer's confidence-evolution writes no-oped until
|
||||||
* read back). User-mutable fields (`confidence`, `weight`, `subtype`)
|
* read back). Governed by {@link BrainyConfig.reservedFieldPolicy} (default
|
||||||
* remap to their dedicated param unless the caller also passed it
|
* `'throw'`): `'throw'` rejects the write; `'warn'`/`'remap'` remap
|
||||||
* (top-level wins); everything else (`noun`, `data`, `createdAt`,
|
* user-mutable fields (`confidence`, `weight`, `subtype`) to their dedicated
|
||||||
* `updatedAt`, `service`, `createdBy`, `_rev`) is system-managed or fixed
|
* param unless the caller also passed it (top-level wins) and drop everything
|
||||||
* at `add()` time and is dropped with a one-shot warning.
|
* 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).
|
* @param params - The caller's update params (not mutated).
|
||||||
* @returns Params with reserved fields normalized out of `metadata`.
|
* @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> {
|
private remapReservedUpdateMetadata(params: UpdateParams<T>): UpdateParams<T> {
|
||||||
const bag = params.metadata as Record<string, unknown> | undefined
|
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)
|
const { reserved, custom } = splitNounMetadataRecord(bag)
|
||||||
if (Object.keys(reserved).length === 0) return params
|
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 {
|
return {
|
||||||
...params,
|
...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
|
* @description Normalize a `relate()` params object with respect to
|
||||||
* Brainy-reserved fields arriving inside `metadata` — the relationship
|
* Brainy-reserved fields arriving inside `metadata` — the relationship
|
||||||
* mirror of {@link remapReservedAddMetadata}. Fields with a dedicated
|
* mirror of {@link remapReservedAddMetadata}. Governed by
|
||||||
* `relate()` param (`confidence`, `weight`, `subtype`, `service`) remap to
|
* {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`): `'throw'`
|
||||||
* that param (top-level wins); system-managed fields (`verb`, `data`,
|
* rejects the write; `'warn'`/`'remap'` remap fields with a dedicated
|
||||||
* `createdAt`, `updatedAt`, `createdBy`, `_rev`) are dropped with a
|
* `relate()` param (`confidence`, `weight`, `subtype`, `visibility`,
|
||||||
* one-shot warning.
|
* `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).
|
* @param params - The caller's relate params (not mutated).
|
||||||
* @returns Params with reserved fields normalized out of `metadata`.
|
* @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> {
|
private remapReservedRelateMetadata(params: RelateParams<T>): RelateParams<T> {
|
||||||
const bag = params.metadata as Record<string, unknown> | undefined
|
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)
|
const { reserved, custom } = splitVerbMetadataRecord(bag)
|
||||||
if (Object.keys(reserved).length === 0) return params
|
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 {
|
return {
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -1954,12 +1986,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
/**
|
/**
|
||||||
* @description Normalize an `updateRelation()` params object with respect
|
* @description Normalize an `updateRelation()` params object with respect
|
||||||
* to Brainy-reserved fields arriving inside the metadata patch — the
|
* to Brainy-reserved fields arriving inside the metadata patch — the
|
||||||
* relationship mirror of {@link remapReservedUpdateMetadata}. User-mutable
|
* relationship mirror of {@link remapReservedUpdateMetadata}. Governed by
|
||||||
* fields (`confidence`, `weight`, `subtype`) remap to their dedicated
|
* {@link BrainyConfig.reservedFieldPolicy} (default `'throw'`): `'throw'`
|
||||||
* param (top-level wins); everything else is dropped with a one-shot
|
* rejects the write; `'warn'`/`'remap'` remap user-mutable fields
|
||||||
* warning.
|
* (`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).
|
* @param params - The caller's update-relation params (not mutated).
|
||||||
* @returns Params with reserved fields normalized out of `metadata`.
|
* @returns Params with reserved fields normalized out of `metadata`.
|
||||||
|
* @throws {Error} When `reservedFieldPolicy` is `'throw'` and the bag carries a reserved key.
|
||||||
*/
|
*/
|
||||||
private remapReservedUpdateRelationMetadata(
|
private remapReservedUpdateRelationMetadata(
|
||||||
params: UpdateRelationParams<T>
|
params: UpdateRelationParams<T>
|
||||||
|
|
@ -1969,7 +2003,9 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
const { reserved, custom } = splitVerbMetadataRecord(bag)
|
const { reserved, custom } = splitVerbMetadataRecord(bag)
|
||||||
if (Object.keys(reserved).length === 0) return params
|
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 {
|
return {
|
||||||
...params,
|
...params,
|
||||||
|
|
@ -11229,7 +11265,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
requireSubtype: config?.requireSubtype ?? true,
|
requireSubtype: config?.requireSubtype ?? true,
|
||||||
// Multi-process safety
|
// Multi-process safety
|
||||||
mode: config?.mode ?? 'writer',
|
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 { SmartDOCXImporter } from '../importers/SmartDOCXImporter.js'
|
||||||
import { VFSStructureGenerator } from '../importers/VFSStructureGenerator.js'
|
import { VFSStructureGenerator } from '../importers/VFSStructureGenerator.js'
|
||||||
import { NounType, VerbType } from '../types/graphTypes.js'
|
import { NounType, VerbType } from '../types/graphTypes.js'
|
||||||
|
import { splitNounMetadataRecord, splitVerbMetadataRecord } from '../types/reservedFields.js'
|
||||||
import { v4 as uuidv4 } from '../universal/uuid.js'
|
import { v4 as uuidv4 } from '../universal/uuid.js'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
|
@ -834,7 +835,11 @@ export class ImportCoordinator {
|
||||||
type: 'media',
|
type: 'media',
|
||||||
description: '',
|
description: '',
|
||||||
confidence: 1.0,
|
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: [],
|
relatedEntities: [],
|
||||||
relationships: []
|
relationships: []
|
||||||
|
|
@ -843,7 +848,8 @@ export class ImportCoordinator {
|
||||||
id: imageId,
|
id: imageId,
|
||||||
name: imageName,
|
name: imageName,
|
||||||
type: 'media',
|
type: 'media',
|
||||||
metadata: { subtype: 'image' }
|
subtype: 'image',
|
||||||
|
metadata: {}
|
||||||
}],
|
}],
|
||||||
relationships: [],
|
relationships: [],
|
||||||
metadata: {},
|
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
|
* Create entities and relationships in knowledge graph
|
||||||
* Added sourceInfo parameter for document entity creation
|
* Added sourceInfo parameter for document entity creation
|
||||||
|
|
@ -977,7 +1015,7 @@ export class ImportCoordinator {
|
||||||
importedAt: trackingContext.importedAt,
|
importedAt: trackingContext.importedAt,
|
||||||
importFormat: trackingContext.importFormat,
|
importFormat: trackingContext.importFormat,
|
||||||
importSource: trackingContext.importSource,
|
importSource: trackingContext.importSource,
|
||||||
...trackingContext.customMetadata
|
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1005,10 +1043,14 @@ export class ImportCoordinator {
|
||||||
data: entity.description || entity.name,
|
data: entity.description || entity.name,
|
||||||
type: entity.type,
|
type: entity.type,
|
||||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
||||||
metadata: {
|
// `confidence` is a reserved field — pass it as the dedicated param,
|
||||||
...entity.metadata,
|
// never inside the metadata bag (8.0 reservedFieldPolicy defaults to 'throw').
|
||||||
name: entity.name,
|
|
||||||
confidence: entity.confidence,
|
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,
|
vfsPath: vfsFile?.path,
|
||||||
importedFrom: 'import-coordinator',
|
importedFrom: 'import-coordinator',
|
||||||
imports: [importSource],
|
imports: [importSource],
|
||||||
|
|
@ -1020,7 +1062,7 @@ export class ImportCoordinator {
|
||||||
importSource: trackingContext.importSource,
|
importSource: trackingContext.importSource,
|
||||||
sourceRow: row.rowNumber,
|
sourceRow: row.rowNumber,
|
||||||
sourceSheet: row.sheet,
|
sourceSheet: row.sheet,
|
||||||
...trackingContext.customMetadata
|
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1101,7 +1143,7 @@ export class ImportCoordinator {
|
||||||
importIds: [trackingContext.importId],
|
importIds: [trackingContext.importId],
|
||||||
projectId: trackingContext.projectId,
|
projectId: trackingContext.projectId,
|
||||||
importFormat: trackingContext.importFormat,
|
importFormat: trackingContext.importFormat,
|
||||||
...trackingContext.customMetadata
|
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1132,10 +1174,12 @@ export class ImportCoordinator {
|
||||||
data: entity.description || entity.name,
|
data: entity.description || entity.name,
|
||||||
type: entity.type,
|
type: entity.type,
|
||||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
subtype: entity.subtype ?? options.defaultSubtype ?? 'imported',
|
||||||
metadata: {
|
// `confidence` is a reserved field — dedicated param, not metadata.
|
||||||
...entity.metadata,
|
|
||||||
name: entity.name,
|
|
||||||
confidence: entity.confidence,
|
confidence: entity.confidence,
|
||||||
|
metadata: {
|
||||||
|
// Strip any reserved keys an extractor smuggled into the bag.
|
||||||
|
...this.stripReservedFromBag(entity.metadata),
|
||||||
|
name: entity.name,
|
||||||
vfsPath: vfsFile?.path,
|
vfsPath: vfsFile?.path,
|
||||||
importedFrom: 'import-coordinator',
|
importedFrom: 'import-coordinator',
|
||||||
// Import tracking metadata
|
// Import tracking metadata
|
||||||
|
|
@ -1148,7 +1192,7 @@ export class ImportCoordinator {
|
||||||
importSource: trackingContext.importSource,
|
importSource: trackingContext.importSource,
|
||||||
sourceRow: row.rowNumber,
|
sourceRow: row.rowNumber,
|
||||||
sourceSheet: row.sheet,
|
sourceSheet: row.sheet,
|
||||||
...trackingContext.customMetadata
|
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1188,7 +1232,7 @@ export class ImportCoordinator {
|
||||||
importIds: [trackingContext.importId],
|
importIds: [trackingContext.importId],
|
||||||
projectId: trackingContext.projectId,
|
projectId: trackingContext.projectId,
|
||||||
importFormat: trackingContext.importFormat,
|
importFormat: trackingContext.importFormat,
|
||||||
...trackingContext.customMetadata
|
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1243,7 +1287,7 @@ export class ImportCoordinator {
|
||||||
projectId: trackingContext.projectId,
|
projectId: trackingContext.projectId,
|
||||||
importedAt: trackingContext.importedAt,
|
importedAt: trackingContext.importedAt,
|
||||||
importFormat: trackingContext.importFormat,
|
importFormat: trackingContext.importFormat,
|
||||||
...trackingContext.customMetadata
|
...this.stripReservedFromBag(trackingContext.customMetadata)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1273,7 +1317,7 @@ export class ImportCoordinator {
|
||||||
projectId: trackingContext.projectId,
|
projectId: trackingContext.projectId,
|
||||||
importedAt: trackingContext.importedAt,
|
importedAt: trackingContext.importedAt,
|
||||||
importFormat: trackingContext.importFormat,
|
importFormat: trackingContext.importFormat,
|
||||||
...trackingContext.customMetadata
|
...this.stripReservedFromRelationBag(trackingContext.customMetadata)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1371,8 +1415,12 @@ export class ImportCoordinator {
|
||||||
from: rel.from,
|
from: rel.from,
|
||||||
to: rel.to,
|
to: rel.to,
|
||||||
type: verbType, // Enhanced type
|
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: {
|
metadata: {
|
||||||
...(rel.metadata || {}),
|
...this.stripReservedFromRelationBag(rel.metadata),
|
||||||
relationshipType: 'semantic', // Distinguish from VFS/provenance
|
relationshipType: 'semantic', // Distinguish from VFS/provenance
|
||||||
inferredType: verbType !== rel.type, // Track if type was enhanced
|
inferredType: verbType !== rel.type, // Track if type was enhanced
|
||||||
originalType: rel.type
|
originalType: rel.type
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import { Brainy } from '../brainy.js'
|
import { Brainy } from '../brainy.js'
|
||||||
import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js'
|
import { VirtualFileSystem } from '../vfs/VirtualFileSystem.js'
|
||||||
import { NounType, VerbType } from '../types/graphTypes.js'
|
import { NounType, VerbType } from '../types/graphTypes.js'
|
||||||
|
import { splitNounMetadataRecord } from '../types/reservedFields.js'
|
||||||
import { SmartExcelImporter, SmartExcelOptions, SmartExcelResult } from './SmartExcelImporter.js'
|
import { SmartExcelImporter, SmartExcelOptions, SmartExcelResult } from './SmartExcelImporter.js'
|
||||||
import { SmartPDFImporter, SmartPDFOptions, SmartPDFResult } from './SmartPDFImporter.js'
|
import { SmartPDFImporter, SmartPDFOptions, SmartPDFResult } from './SmartPDFImporter.js'
|
||||||
import { SmartCSVImporter, SmartCSVOptions, SmartCSVResult } from './SmartCSVImporter.js'
|
import { SmartCSVImporter, SmartCSVOptions, SmartCSVResult } from './SmartCSVImporter.js'
|
||||||
|
|
@ -201,7 +202,9 @@ export class SmartImportOrchestrator {
|
||||||
.subtype ?? options.defaultSubtype ?? 'imported',
|
.subtype ?? options.defaultSubtype ?? 'imported',
|
||||||
confidence: extracted.entity.confidence, // reserved field — dedicated param, not metadata
|
confidence: extracted.entity.confidence, // reserved field — dedicated param, not metadata
|
||||||
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,
|
name: extracted.entity.name,
|
||||||
importedFrom: 'smart-import'
|
importedFrom: 'smart-import'
|
||||||
}
|
}
|
||||||
|
|
@ -245,7 +248,7 @@ export class SmartImportOrchestrator {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all relationship parameters
|
// 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 extracted of result.extraction.rows) {
|
||||||
for (const rel of extracted.relationships) {
|
for (const rel of extracted.relationships) {
|
||||||
|
|
@ -288,8 +291,8 @@ export class SmartImportOrchestrator {
|
||||||
subtype:
|
subtype:
|
||||||
(rel as typeof rel & { subtype?: string }).subtype ??
|
(rel as typeof rel & { subtype?: string }).subtype ??
|
||||||
options.defaultSubtype ?? 'imported',
|
options.defaultSubtype ?? 'imported',
|
||||||
|
confidence: rel.confidence, // reserved field — dedicated param, not metadata
|
||||||
metadata: {
|
metadata: {
|
||||||
confidence: rel.confidence,
|
|
||||||
evidence: rel.evidence
|
evidence: rel.evidence
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -615,7 +618,7 @@ export class SmartImportOrchestrator {
|
||||||
(extracted.entity as typeof extracted.entity & { subtype?: string })
|
(extracted.entity as typeof extracted.entity & { subtype?: string })
|
||||||
.subtype ?? options.defaultSubtype ?? 'imported',
|
.subtype ?? options.defaultSubtype ?? 'imported',
|
||||||
confidence: extracted.entity.confidence, // reserved field — dedicated param, not metadata
|
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.entityIds.push(entityId)
|
||||||
result.stats.entitiesCreated++
|
result.stats.entitiesCreated++
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import { Brainy } from '../brainy.js'
|
import { Brainy } from '../brainy.js'
|
||||||
import { NounType, VerbType } from '../types/graphTypes.js'
|
import { NounType, VerbType } from '../types/graphTypes.js'
|
||||||
|
import { splitNounMetadataRecord, splitVerbMetadataRecord } from '../types/reservedFields.js'
|
||||||
import * as fs from '../universal/fs.js'
|
import * as fs from '../universal/fs.js'
|
||||||
import * as path from '../universal/path.js'
|
import * as path from '../universal/path.js'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
@ -802,9 +803,12 @@ export class NeuralImport {
|
||||||
data: this.extractMainText(entity.originalData),
|
data: this.extractMainText(entity.originalData),
|
||||||
type: entity.nounType as NounType,
|
type: entity.nounType as NounType,
|
||||||
subtype: entity.subtype ?? options.defaultSubtype ?? 'extracted',
|
subtype: entity.subtype ?? options.defaultSubtype ?? 'extracted',
|
||||||
metadata: {
|
// `confidence` is a reserved field — dedicated param, not metadata
|
||||||
...entity.originalData,
|
// (8.0 reservedFieldPolicy defaults to 'throw').
|
||||||
confidence: entity.confidence,
|
confidence: entity.confidence,
|
||||||
|
metadata: {
|
||||||
|
// Strip any reserved keys the source data smuggled into the bag.
|
||||||
|
...splitNounMetadataRecord(entity.originalData).custom,
|
||||||
id: entity.suggestedId
|
id: entity.suggestedId
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -821,7 +825,8 @@ export class NeuralImport {
|
||||||
confidence: relationship.confidence, // reserved field — dedicated param, not metadata
|
confidence: relationship.confidence, // reserved field — dedicated param, not metadata
|
||||||
metadata: {
|
metadata: {
|
||||||
context: relationship.context,
|
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.
|
* (PID liveness + heartbeat) cannot prove it. Logs a warning regardless.
|
||||||
*/
|
*/
|
||||||
force?: boolean
|
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 =============
|
// ============= Neural API Types =============
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,8 @@ describe('Performance Benchmarks - SLA Validation', () => {
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
await brainy.update({
|
await brainy.update({
|
||||||
id,
|
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
|
const latency = performance.now() - start
|
||||||
benchmark.recordOperation(latency)
|
benchmark.recordOperation(latency)
|
||||||
|
|
|
||||||
|
|
@ -39,17 +39,21 @@ export function generateTestVector(dimension = 384): Vector {
|
||||||
return vector.map(val => val / magnitude)
|
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 {
|
export function generateTestMetadata(overrides: any = {}): any {
|
||||||
return {
|
return {
|
||||||
name: `Test Item ${generateTestId()}`,
|
name: `Test Item ${generateTestId()}`,
|
||||||
description: 'Test description',
|
description: 'Test description',
|
||||||
tags: ['test', 'automated'],
|
tags: ['test', 'automated'],
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
version: 1,
|
version: 1,
|
||||||
source: 'test-factory',
|
source: 'test-factory',
|
||||||
confidence: 0.95,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,12 @@ describe('Batch Import with Immediate Relations (v5.7.3 Fix)', () => {
|
||||||
const entityParams = Array(372).fill(null).map((_, i) => ({
|
const entityParams = Array(372).fill(null).map((_, i) => ({
|
||||||
data: `Extracted Entity ${i}`,
|
data: `Extracted Entity ${i}`,
|
||||||
type: NounType.Thing,
|
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: {
|
metadata: {
|
||||||
extractedFrom: 'TfT~Sapient Species.pdf',
|
extractedFrom: 'TfT~Sapient Species.pdf',
|
||||||
entityNumber: i,
|
entityNumber: i
|
||||||
confidence: 0.9
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@ describe('Remaining APIs Comprehensive Test', () => {
|
||||||
await brain.updateMany({
|
await brain.updateMany({
|
||||||
items: ids.successful.map(id => ({
|
items: ids.successful.map(id => ({
|
||||||
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
|
* consumer's confidence-evolution writes no-oped for weeks before being
|
||||||
* caught by reading values back.
|
* 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
|
* - user-mutable reserved fields (`confidence`, `weight`, `subtype` — plus
|
||||||
* `service`/`createdBy` at add()/relate() time) remap from the metadata
|
* `service`/`createdBy` at add()/relate() time) remap from the metadata
|
||||||
* bag to their dedicated top-level param, with top-level winning when both
|
* bag to their dedicated top-level param, with top-level winning when both
|
||||||
* are present;
|
* are present;
|
||||||
* - system-managed reserved fields (`createdAt`, `_rev`, `noun`/`verb`,
|
* - 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()`
|
* - the same normalization applies to `transact()` operations and `with()`
|
||||||
* speculative views;
|
* speculative views;
|
||||||
* - reads NEVER echo a reserved field inside `metadata`.
|
* - 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 { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||||
import { createTestConfig } from '../../helpers/test-factory.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
|
let brain: Brainy
|
||||||
|
|
||||||
beforeEach(async () => {
|
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()
|
await brain.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -198,36 +198,60 @@ describe('visibility (8.0 reserved field)', () => {
|
||||||
expect(entity?.visibility).toBeUndefined()
|
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.
|
// 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,
|
type: NounType.Concept,
|
||||||
data: 'y',
|
data: 'y',
|
||||||
metadata: { visibility: 'internal', tag: 't' } as object
|
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…
|
// Lifted to the top-level field…
|
||||||
expect(entity?.visibility).toBe('internal')
|
expect(entity?.visibility).toBe('internal')
|
||||||
// …and stripped from the metadata bag.
|
// …and stripped from the metadata bag.
|
||||||
expect((entity?.metadata as Record<string, unknown>)?.visibility).toBeUndefined()
|
expect((entity?.metadata as Record<string, unknown>)?.visibility).toBeUndefined()
|
||||||
expect((entity?.metadata as Record<string, unknown>)?.tag).toBe('t')
|
expect((entity?.metadata as Record<string, unknown>)?.tag).toBe('t')
|
||||||
// It is excluded from the default count, exactly like a top-level internal write.
|
// 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.
|
// '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,
|
type: NounType.Concept,
|
||||||
data: 'z',
|
data: 'z',
|
||||||
metadata: { visibility: 'system' } as object
|
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).
|
// The smuggled 'system' was dropped → entity stays public (counted, visible).
|
||||||
expect(entity?.visibility).toBeUndefined()
|
expect(entity?.visibility).toBeUndefined()
|
||||||
expect(await brain.getNounCount()).toBe(1)
|
expect(await remapBrain.getNounCount()).toBe(1)
|
||||||
const found = await brain.find({ type: NounType.Concept, limit: 10 })
|
const found = await remapBrain.find({ type: NounType.Concept, limit: 10 })
|
||||||
expect(found.map((r) => r.id)).toContain(id)
|
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