/** * @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 = 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 = { readonly [K in ReservedEntityField as K extends keyof T ? never : K]?: never } /** * @description Relationship mirror of {@link NoReservedEntityKeys}. */ export type NoReservedRelationKeys = { 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 = { [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 = IsAny extends true ? OpenBag> : T & NoReservedEntityKeys /** * @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 = IsAny extends true ? OpenBag> : Partial & NoReservedEntityKeys /** * @description The type of `RelateParams.metadata`: the consumer's edge * metadata shape with reserved relationship keys forbidden at compile time. */ export type RelationMetadataInput = IsAny extends true ? OpenBag> : T & NoReservedRelationKeys /** * @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 = IsAny extends true ? OpenBag> : Partial & NoReservedRelationKeys /** * @description Result of splitting a stored flat metadata record into its * reserved (Brainy-owned) and custom (consumer-owned) halves. */ export interface SplitMetadataRecord { /** The reserved fields present in the record, keyed by reserved name. */ reserved: Partial> /** Every other key — the consumer's custom metadata, and nothing else. */ custom: Record } const RESERVED_ENTITY_SET: ReadonlySet = new Set(RESERVED_ENTITY_FIELDS) const RESERVED_RELATION_SET: ReadonlySet = 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( record: Record | null | undefined, reservedSet: ReadonlySet ): SplitMetadataRecord { const reserved: Record = {} const custom: Record = {} 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>, 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 | null | undefined ): SplitMetadataRecord { 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 | null | undefined ): SplitMetadataRecord { return splitRecord(record, RESERVED_RELATION_SET) }