open-brainy/src/types/reservedFields.ts
David Snelling f4dea80176 feat(8.0): visibility field (public/internal/system) on nouns + verbs
Adds a reserved, top-level `visibility` field (mirrors the subtype rollout):
'public' (default, surfaced) | 'internal' (developer app-internal — hidden from
default find/count/stats, opt-in via includeInternal) | 'system' (Brainy
plumbing, library-set only).

Fixes a real leak: the VFS root entity counted in getNounCount() and appeared in
find() (a fresh brain reported 1 entity). It is now visibility:'system' →
excluded from every user-facing surface. Developers also get a first-class
hidden-unless-asked tier (e.g. learned internals vs user-exposed data).

- Reserved (RESERVED_ENTITY_FIELDS / RESERVED_RELATION_FIELDS) — spoof-proof from metadata.
- Threaded through add/relate/update/transact; surfaced top-level on reads.
- Default exclusion in counts (baseStorage), find()/related() (hard candidate
  filter via excludeVisibility — keeps topK/limit correct), and stats;
  includeInternal/includeSystem opt-ins.
- VFS root marked 'system'.

Tests: visibility.test.ts 17/17 (fresh-brain getNounCount()===0, internal hidden
+ opt-in, verb symmetry, top-level surfacing, metadata-spoof rejection). Unit
1431 green; count-synchronization integration now passes (off-by-one fixed).
2026-06-16 15:20:26 -07:00

254 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 |
* | `visibility` | the `visibility` param (`'public'` \| `'internal'`; `'system'` is Brainy-only) |
* | `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',
'visibility',
'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 |
* | `visibility` | the `visibility` param (`'public'` \| `'internal'`; `'system'` is Brainy-only) |
* | `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',
'visibility',
'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)
}