open-brainy/src/types/reservedFields.ts
David Snelling 970e08c466 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.
2026-06-11 13:13:09 -07:00

250 lines
10 KiB
TypeScript

/**
* @module types/reservedFields
* @description The canonical reserved-field contract — ONE place that defines
* which keys belong to Brainy (top-level entity/relationship fields) and may
* therefore never live inside a `metadata` bag.
*
* Three layers enforce the contract, all driven by the constants below:
*
* 1. **Compile time** — `AddParams.metadata`, `UpdateParams.metadata`,
* `RelateParams.metadata` and `UpdateRelationParams.metadata` are typed so
* a literal reserved key is a TypeScript error (see
* {@link EntityMetadataInput} / {@link RelationMetadataInput}).
* 2. **Write time** — for untyped (JavaScript) callers that smuggle a
* reserved key past the compiler anyway, every write path normalizes the
* bag: user-mutable fields are remapped to their dedicated top-level
* param (top-level wins when both are supplied) and system-managed fields
* are dropped with a one-shot warning naming the correct write path.
* 3. **Read time** — every read path splits the stored flat record through
* {@link splitNounMetadataRecord} / {@link splitVerbMetadataRecord}, so a
* reserved field is surfaced ONLY at top level and `entity.metadata` /
* `relation.metadata` contain ONLY custom fields, always — live reads,
* batch reads, and historical (`asOf`) reads alike.
*
* Documented for consumers in `docs/concepts/consistency-model.md`
* ("Reserved fields").
*/
/**
* @description Entity (noun) field names reserved by Brainy. These keys are
* stored in the flat per-entity metadata record alongside custom fields, but
* they belong to Brainy: every read path extracts them to top-level
* `Entity` fields, and no write path accepts them inside `metadata`.
*
* | Key | Canonical write path |
* |-----|----------------------|
* | `noun` | the `type` param of `add()` / `update()` (stored under the key `noun`) |
* | `subtype` | the `subtype` param |
* | `createdAt` | system-managed — set once at `add()` time |
* | `updatedAt` | system-managed — set on every write |
* | `confidence` | the `confidence` param |
* | `weight` | the `weight` param |
* | `service` | the `service` param of `add()` (immutable afterwards) |
* | `data` | the `data` param |
* | `createdBy` | the `createdBy` param of `add()` (immutable afterwards) |
* | `_rev` | system-managed revision counter — pass `ifRev` to `update()` for CAS |
*
* @example
* import { RESERVED_ENTITY_FIELDS } from '@soulcraft/brainy'
* const isReserved = (key: string) =>
* (RESERVED_ENTITY_FIELDS as readonly string[]).includes(key)
*/
export const RESERVED_ENTITY_FIELDS = [
'noun',
'subtype',
'createdAt',
'updatedAt',
'confidence',
'weight',
'service',
'data',
'createdBy',
'_rev'
] as const
/**
* @description Union of the entity field names reserved by Brainy — the
* element type of {@link RESERVED_ENTITY_FIELDS}.
*/
export type ReservedEntityField = (typeof RESERVED_ENTITY_FIELDS)[number]
/**
* @description Relationship (verb) field names reserved by Brainy — the verb
* mirror of {@link RESERVED_ENTITY_FIELDS}. The stored flat record keys the
* relationship type under `verb` (the public `Relation` field is `type`);
* everything else matches the entity list.
*
* | Key | Canonical write path |
* |-----|----------------------|
* | `verb` | the `type` param of `relate()` / `updateRelation()` (stored under the key `verb`) |
* | `subtype` | the `subtype` param |
* | `createdAt` | system-managed — set once at `relate()` time |
* | `updatedAt` | system-managed — set on every write |
* | `confidence` | the `confidence` param |
* | `weight` | the `weight` param |
* | `service` | the `service` param of `relate()` (immutable afterwards) |
* | `data` | the `data` param |
* | `createdBy` | system-managed |
* | `_rev` | system-managed |
*/
export const RESERVED_RELATION_FIELDS = [
'verb',
'subtype',
'createdAt',
'updatedAt',
'confidence',
'weight',
'service',
'data',
'createdBy',
'_rev'
] as const
/**
* @description Union of the relationship field names reserved by Brainy —
* the element type of {@link RESERVED_RELATION_FIELDS}.
*/
export type ReservedRelationField = (typeof RESERVED_RELATION_FIELDS)[number]
/**
* @description `true` when `T` is exactly `any` (the classic
* `0 extends 1 & T` probe — only `any` absorbs the impossible intersection).
* Used to keep the reserved-key guard active for untyped brains, where a
* plain `T & guard` intersection would collapse to `any` and check nothing.
*/
type IsAny<T> = 0 extends 1 & T ? true : false
/**
* @description Compile-time tripwire: marks every reserved entity key as
* `never` so an object literal carrying one fails to type-check. Keys that
* `T` itself declares (including via an index signature, where
* `keyof T = string`) are exempted — a consumer who *explicitly* types a
* reserved key into their metadata shape keeps a working (if unwise) type,
* and index-signature metadata types remain assignable.
*/
export type NoReservedEntityKeys<T> = {
readonly [K in ReservedEntityField as K extends keyof T ? never : K]?: never
}
/**
* @description Relationship mirror of {@link NoReservedEntityKeys}.
*/
export type NoReservedRelationKeys<T> = {
readonly [K in ReservedRelationField as K extends keyof T ? never : K]?: never
}
/**
* @description The metadata bag shape for untyped brains (`T = any`): an
* open index signature (any custom key, any value — exactly the pre-8.0
* latitude) intersected with the reserved-key guard, whose declared
* `?: never` properties take precedence over the index signature so a
* literal reserved key is still a compile error.
*/
type OpenBag<Guard> = { [key: string]: any } & Guard
/**
* @description The type of `AddParams.metadata`: the consumer's metadata
* shape `T` with reserved entity keys forbidden at compile time. For untyped
* brains (`T = any`) the bag stays open ({@link OpenBag}), so arbitrary
* custom fields remain legal while literal reserved keys still error.
*/
export type EntityMetadataInput<T> = IsAny<T> extends true
? OpenBag<NoReservedEntityKeys<object>>
: T & NoReservedEntityKeys<T>
/**
* @description The type of `UpdateParams.metadata`: a partial patch of the
* consumer's metadata shape with reserved entity keys forbidden at compile
* time. Same `T = any` handling as {@link EntityMetadataInput}.
*/
export type EntityMetadataPatch<T> = IsAny<T> extends true
? OpenBag<NoReservedEntityKeys<object>>
: Partial<T> & NoReservedEntityKeys<T>
/**
* @description The type of `RelateParams.metadata`: the consumer's edge
* metadata shape with reserved relationship keys forbidden at compile time.
*/
export type RelationMetadataInput<T> = IsAny<T> extends true
? OpenBag<NoReservedRelationKeys<object>>
: T & NoReservedRelationKeys<T>
/**
* @description The type of `UpdateRelationParams.metadata`: a partial patch
* of the consumer's edge metadata shape with reserved relationship keys
* forbidden at compile time.
*/
export type RelationMetadataPatch<T> = IsAny<T> extends true
? OpenBag<NoReservedRelationKeys<object>>
: Partial<T> & NoReservedRelationKeys<T>
/**
* @description Result of splitting a stored flat metadata record into its
* reserved (Brainy-owned) and custom (consumer-owned) halves.
*/
export interface SplitMetadataRecord<F extends string> {
/** The reserved fields present in the record, keyed by reserved name. */
reserved: Partial<Record<F, unknown>>
/** Every other key — the consumer's custom metadata, and nothing else. */
custom: Record<string, unknown>
}
const RESERVED_ENTITY_SET: ReadonlySet<string> = new Set(RESERVED_ENTITY_FIELDS)
const RESERVED_RELATION_SET: ReadonlySet<string> = new Set(RESERVED_RELATION_FIELDS)
/**
* @description Shared splitter — partitions a record's keys against a
* reserved-name set. `null`/`undefined` records split to two empty objects.
* @param record - The stored flat metadata record (reserved + custom keys mixed).
* @param reservedSet - The reserved-name set to partition against.
* @returns The `{ reserved, custom }` halves.
*/
function splitRecord<F extends string>(
record: Record<string, unknown> | null | undefined,
reservedSet: ReadonlySet<string>
): SplitMetadataRecord<F> {
const reserved: Record<string, unknown> = {}
const custom: Record<string, unknown> = {}
if (record && typeof record === 'object') {
for (const [key, value] of Object.entries(record)) {
if (reservedSet.has(key)) {
reserved[key] = value
} else {
custom[key] = value
}
}
}
return { reserved: reserved as Partial<Record<F, unknown>>, custom }
}
/**
* @description Split a stored entity (noun) flat metadata record into
* reserved fields and custom metadata — THE canonical read-side split. Every
* entity read path (live `get()`, batch reads, paginated listings, and
* historical `asOf()` materialization) goes through this function, so the
* reserved list can never drift between read paths.
* @param record - The stored flat metadata record.
* @returns `reserved` (Brainy-owned fields) and `custom` (the consumer's metadata bag).
* @example
* const { reserved, custom } = splitNounMetadataRecord(stored)
* // reserved.noun → entity.type, reserved.confidence → entity.confidence, …
* // custom → entity.metadata (custom fields only, always)
*/
export function splitNounMetadataRecord(
record: Record<string, unknown> | null | undefined
): SplitMetadataRecord<ReservedEntityField> {
return splitRecord(record, RESERVED_ENTITY_SET)
}
/**
* @description Split a stored relationship (verb) flat metadata record into
* reserved fields and custom metadata — the verb mirror of
* {@link splitNounMetadataRecord}, used by every relationship read path.
* @param record - The stored flat metadata record.
* @returns `reserved` (Brainy-owned fields) and `custom` (the consumer's metadata bag).
*/
export function splitVerbMetadataRecord(
record: Record<string, unknown> | null | undefined
): SplitMetadataRecord<ReservedRelationField> {
return splitRecord(record, RESERVED_RELATION_SET)
}