251 lines
10 KiB
TypeScript
251 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)
|
||
|
|
}
|