feat(8.0): reserved-field contract — one canonical location, typed prevention, unified read/write
Brainy-owned field names (noun/verb, subtype, createdAt, updatedAt,
confidence, weight, service, data, createdBy, _rev) now have exactly one
home — top level — enforced by three layers driven from a single source of
truth, src/types/reservedFields.ts (RESERVED_ENTITY_FIELDS /
RESERVED_RELATION_FIELDS, exported):
1. Compile time — AddParams/UpdateParams/RelateParams/UpdateRelationParams
metadata (and the transact() ops that extend them) reject a literal
reserved key as a TypeScript error while keeping generic T ergonomics
(typed bags, untyped brains, index-signature shapes, and a documented
exemption for T-declared reserved keys). Pinned by @ts-expect-error
type tests run under vitest typecheck mode on every unit run.
2. Write time — the 7.x update() remap is ported to 8.0 and extended to
every write path: add/update/relate/updateRelation, their transact()
mirrors, and db.with() overlays. User-settable fields lift to their
dedicated param (top-level wins when both are supplied — closes the 7.x
trap where update({metadata:{confidence}}) silently no-oped), and
system-managed fields drop with a one-shot warning naming the right
path. A remapped subtype satisfies subtype-pairing enforcement exactly
like a top-level one.
3. Read time — every storage combine goes through one canonical hydration
helper (hydrateNounWithMetadata / hydrateVerbWithMetadata over
splitNoun/VerbMetadataRecord), so reserved fields surface ONLY top-level
and entity/relation.metadata carry ONLY custom fields on live reads,
batch reads, paginated listings, getRelations by source/target, streamed
verbs, and historical asOf() materialization alike.
Read-path echoes found and fixed (previously the full stored record —
including the verb type key — leaked inside metadata): noun pagination,
verb pagination, getVerbsBySource/ByTarget (adjacency + shard fallback),
getVerbsBySourceBatch (which also dropped subtype/data), and the
filesystem verb stream. getRelations() results now surface
confidence/updatedAt top-level via verbsToRelations, updateRelation() no
longer erases service/createdBy, relate() persists its top-level
confidence/service params, and the dead convertHNSWVerbToGraphVerb echo
path is deleted. Import paths (CLI extract, deduplicator, coordinators,
neural import) write confidence through the dedicated param instead of the
bag. UpdateRelationParams is now exported from the package root.
Documented for consumers in docs/concepts/consistency-model.md ("Reserved
fields") and RELEASES.md. Regression tests ported from the 7.x fix and
extended to the full 8.0 contract (17 runtime tests + 41 type-level
assertions); full unit suite 1427/1427, db-mvcc integration 24/24.
This commit is contained in:
parent
c44678390e
commit
970e08c466
18 changed files with 1688 additions and 452 deletions
265
tests/unit/types/reserved-metadata-keys.test-d.ts
Normal file
265
tests/unit/types/reserved-metadata-keys.test-d.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* @module tests/unit/types/reserved-metadata-keys.test-d
|
||||
* @description Compile-time tests for the reserved-field contract (layer 1 of
|
||||
* three — see src/types/reservedFields.ts): a literal reserved key inside any
|
||||
* `metadata` param is a TypeScript error, while the generic `T` ergonomics
|
||||
* stay intact (typed bags, untyped brains, index-signature shapes, and the
|
||||
* documented exemption for consumers who explicitly declare a reserved key in
|
||||
* their own metadata type).
|
||||
*
|
||||
* Runs under vitest typecheck mode (`test.typecheck` in
|
||||
* tests/configs/vitest.unit.config.ts) — these assertions are validated by
|
||||
* `tsc`, never executed. The runtime half of the contract (the write-path
|
||||
* remap for untyped callers) is pinned by
|
||||
* tests/unit/brainy/update-reserved-metadata-remap.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, assertType } from 'vitest'
|
||||
import type {
|
||||
AddParams,
|
||||
UpdateParams,
|
||||
RelateParams,
|
||||
UpdateRelationParams,
|
||||
TxOperation
|
||||
} from '../../../src/index.js'
|
||||
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
||||
|
||||
describe('reserved entity keys in metadata are compile errors', () => {
|
||||
it('AddParams (untyped brain) rejects every reserved key but stays open for custom fields', () => {
|
||||
// Custom fields of any shape remain legal — exactly the pre-8.0 latitude.
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
metadata: { dept: 'eng', level: 3, tags: ['a', 'b'], nested: { ok: true } }
|
||||
})
|
||||
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'noun' is reserved (the entity type travels via the top-level 'type' param)
|
||||
metadata: { noun: 'organization' }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'subtype' is reserved (use the top-level 'subtype' param)
|
||||
metadata: { subtype: 'contractor' }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'createdAt' is reserved (system-managed)
|
||||
metadata: { createdAt: Date.now() }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'updatedAt' is reserved (system-managed)
|
||||
metadata: { updatedAt: Date.now() }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param)
|
||||
metadata: { confidence: 0.8 }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'weight' is reserved (use the top-level 'weight' param)
|
||||
metadata: { weight: 0.5 }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'service' is reserved (use the top-level 'service' param)
|
||||
metadata: { service: 'orders' }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'data' is reserved (use the top-level 'data' param)
|
||||
metadata: { data: 'content' }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'createdBy' is reserved (use the top-level 'createdBy' param)
|
||||
metadata: { createdBy: { augmentation: 'importer', version: '1.0' } }
|
||||
})
|
||||
assertType<AddParams>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — '_rev' is reserved (system-managed revision counter)
|
||||
metadata: { _rev: 7 }
|
||||
})
|
||||
})
|
||||
|
||||
it('AddParams<T> (typed brain) rejects reserved keys alongside the declared shape', () => {
|
||||
interface EmployeeMeta {
|
||||
dept: string
|
||||
level: number
|
||||
}
|
||||
|
||||
assertType<AddParams<EmployeeMeta>>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
metadata: { dept: 'eng', level: 3 }
|
||||
})
|
||||
|
||||
assertType<AddParams<EmployeeMeta>>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
// @ts-expect-error — 'confidence' is reserved even when T declares other fields
|
||||
metadata: { dept: 'eng', level: 3, confidence: 0.8 }
|
||||
})
|
||||
})
|
||||
|
||||
it('documented exemptions: T-declared reserved keys and index-signature shapes stay assignable', () => {
|
||||
// A consumer who *explicitly* types a reserved key into their metadata
|
||||
// shape keeps a working (if unwise) type — the guard exempts keyof T.
|
||||
interface LegacyMeta {
|
||||
confidence: number
|
||||
note: string
|
||||
}
|
||||
assertType<AddParams<LegacyMeta>>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
metadata: { confidence: 0.8, note: 'declared by the consumer type' }
|
||||
})
|
||||
|
||||
// Index-signature metadata types (keyof T = string) remain fully open.
|
||||
assertType<AddParams<Record<string, unknown>>>({
|
||||
type: NounType.Person,
|
||||
subtype: 'employee',
|
||||
data: 'x',
|
||||
metadata: { anything: 'goes', confidence: 0.8 }
|
||||
})
|
||||
})
|
||||
|
||||
it('UpdateParams patch rejects reserved keys but accepts partial custom patches', () => {
|
||||
interface EmployeeMeta {
|
||||
dept: string
|
||||
level: number
|
||||
}
|
||||
|
||||
// Partial patch of the declared shape is legal.
|
||||
assertType<UpdateParams<EmployeeMeta>>({ id: 'e1', metadata: { dept: 'sales' } })
|
||||
// Untyped patch with custom fields is legal.
|
||||
assertType<UpdateParams>({ id: 'e1', metadata: { status: 'reviewed', rating: 4.5 } })
|
||||
|
||||
// @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param)
|
||||
assertType<UpdateParams>({ id: 'e1', metadata: { confidence: 0.33 } })
|
||||
// @ts-expect-error — 'subtype' is reserved (use the top-level 'subtype' param)
|
||||
assertType<UpdateParams>({ id: 'e1', metadata: { subtype: 'specialized' } })
|
||||
// @ts-expect-error — '_rev' is reserved (pass 'ifRev' for optimistic concurrency)
|
||||
assertType<UpdateParams>({ id: 'e1', metadata: { _rev: 3 } })
|
||||
// @ts-expect-error — 'confidence' is reserved even when T declares other fields
|
||||
assertType<UpdateParams<EmployeeMeta>>({ id: 'e1', metadata: { confidence: 0.1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('reserved relationship keys in metadata are compile errors', () => {
|
||||
it('RelateParams rejects reserved keys but stays open for custom edge fields', () => {
|
||||
assertType<RelateParams>({
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct',
|
||||
metadata: { role: 'peer', since: 2024 }
|
||||
})
|
||||
|
||||
assertType<RelateParams>({
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct',
|
||||
// @ts-expect-error — 'verb' is reserved (the relationship type travels via the top-level 'type' param)
|
||||
metadata: { verb: 'relatedTo' }
|
||||
})
|
||||
assertType<RelateParams>({
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct',
|
||||
// @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param)
|
||||
metadata: { confidence: 0.9 }
|
||||
})
|
||||
assertType<RelateParams>({
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct',
|
||||
// @ts-expect-error — 'weight' is reserved (use the top-level 'weight' param)
|
||||
metadata: { weight: 0.4 }
|
||||
})
|
||||
assertType<RelateParams>({
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
type: VerbType.ReportsTo,
|
||||
subtype: 'direct',
|
||||
// @ts-expect-error — 'service' is reserved (use the top-level 'service' param)
|
||||
metadata: { service: 'orders' }
|
||||
})
|
||||
})
|
||||
|
||||
it('UpdateRelationParams patch rejects reserved keys', () => {
|
||||
assertType<UpdateRelationParams>({ id: 'r1', metadata: { note: 'fine' } })
|
||||
|
||||
// @ts-expect-error — 'confidence' is reserved (use the top-level 'confidence' param)
|
||||
assertType<UpdateRelationParams>({ id: 'r1', metadata: { confidence: 0.5 } })
|
||||
// @ts-expect-error — 'subtype' is reserved (use the top-level 'subtype' param)
|
||||
assertType<UpdateRelationParams>({ id: 'r1', metadata: { subtype: 'dotted-line' } })
|
||||
// @ts-expect-error — 'createdAt' is reserved (system-managed)
|
||||
assertType<UpdateRelationParams>({ id: 'r1', metadata: { createdAt: 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('transact() operations inherit the same guard', () => {
|
||||
it('TxOperation add/update/relate metadata rejects reserved keys', () => {
|
||||
assertType<TxOperation>({
|
||||
op: 'add',
|
||||
type: NounType.Concept,
|
||||
subtype: 'general',
|
||||
data: 'tx',
|
||||
metadata: { custom: 'a' }
|
||||
})
|
||||
assertType<TxOperation>({
|
||||
op: 'add',
|
||||
type: NounType.Concept,
|
||||
subtype: 'general',
|
||||
data: 'tx',
|
||||
// @ts-expect-error — 'confidence' is reserved on transact add ops too
|
||||
metadata: { confidence: 0.7 }
|
||||
})
|
||||
assertType<TxOperation>({
|
||||
op: 'update',
|
||||
id: 'e1',
|
||||
// @ts-expect-error — 'weight' is reserved on transact update ops too
|
||||
metadata: { weight: 0.2 }
|
||||
})
|
||||
assertType<TxOperation>({
|
||||
op: 'relate',
|
||||
from: 'a',
|
||||
to: 'b',
|
||||
type: VerbType.RelatedTo,
|
||||
subtype: 'colleague',
|
||||
// @ts-expect-error — 'verb' is reserved on transact relate ops too
|
||||
metadata: { verb: 'contains' }
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue