diff --git a/src/db/fieldAddressing.ts b/src/db/fieldAddressing.ts new file mode 100644 index 00000000..0ee09a05 --- /dev/null +++ b/src/db/fieldAddressing.ts @@ -0,0 +1,246 @@ +/** + * @module db/fieldAddressing + * @description The one field-addressing law for every query surface (find()'s + * `where` / `orderBy` / `groupBy`, aggregation `source.where`), ruled + * 2026-08-03 after a production incident in which a user metadata field + * named `level` was silently shadowed by the engine's internal HNSW node + * layer (VENUE-BRAINY-ORDERBY-NOOP — thread id kept verbatim as the audit + * key; it names no product): + * + * 1. A BARE field name addresses the user's metadata field. Always. + * No priority resolution, no fallback chain — `orderBy: 'level'` + * reads `entity.metadata.level`, full stop. + * 2. `system.` addresses an engine scalar, reachable ONLY with the + * explicit prefix. The entity map is exactly ten scalars; the relation + * map mirrors it with `verb`/`sourceId`/`targetId` as the structural + * members. + * 3. Engine plumbing (`vector`, `connections`, `level`, `data`, `_rev`) is + * INVISIBLE to the query surface in either spelling — `system.level` + * refuses; bare `level` is the user's field. + * 4. `metadata.` is the explicit spelling of the bare form — + * identical semantics on every path. + * 5. Anything unresolvable refuses with a TYPED error naming both + * candidate spellings — an accepted name either works or refuses; + * there is no third state. + * + * This module is the SINGLE source of truth for the law: parsing, the maps, + * and the refusal builders live here so the JS engine, the provider seams, + * and the cross-engine conformance suite can never drift on the contract. + */ + +import type { HNSWNounWithMetadata, HNSWVerbWithMetadata } from '../coreTypes.js' + +/** + * @description The entity-side `system.*` map — EXACTLY the ten engine + * scalars David ruled queryable (2026-08-03). Adding a name here is a + * cross-engine contract change: the native accelerator's conformance suite + * pins this list verbatim, so any edit must ship as a paired release. + */ +export const SYSTEM_ENTITY_SCALARS: ReadonlySet = new Set([ + 'id', + 'type', + 'subtype', + 'createdAt', + 'updatedAt', + 'confidence', + 'weight', + 'visibility', + 'service', + 'createdBy' +]) + +/** + * @description The relation-side `system.*` map — the verb mirror of + * {@link SYSTEM_ENTITY_SCALARS}: `verb`, `sourceId`, `targetId` are the + * structural members beside the eight shared scalars. Same one law, same + * pairing rule for edits. + */ +export const SYSTEM_RELATION_SCALARS: ReadonlySet = new Set([ + 'verb', + 'sourceId', + 'targetId', + 'subtype', + 'createdAt', + 'updatedAt', + 'confidence', + 'weight', + 'visibility', + 'service', + 'createdBy' +]) + +/** + * @description Engine plumbing — never addressable from the query surface in + * ANY spelling. `level` is the HNSW node layer (the incident field: listing + * it as resolvable shadowed real user data); `data` is the payload container, + * not a scalar — content is reached through the content/text-search APIs, + * and addressing it as a sortable field would lie about its shape. + */ +export const PLUMBING_FIELDS: ReadonlySet = new Set([ + 'vector', + 'connections', + 'level', + 'data', + '_rev' +]) + +/** @description Which record kind a field address is being resolved against. */ +export type FieldAddressKind = 'entity' | 'relation' + +/** + * @description A parsed, law-valid field address. `scope` says which side of + * the record the name lives on; `field` is the unprefixed name to read. + */ +export interface FieldAddress { + /** 'metadata' = the user's field (bare or `metadata.`-prefixed); 'system' = an engine scalar. */ + scope: 'metadata' | 'system' + /** The field name with any scope prefix removed. */ + field: string + /** The exact spelling the caller used — preserved for error text and telemetry. */ + raw: string +} + +/** + * Parse a query-surface field name under the one law. Pure and data-blind: + * this validates the ADDRESS (spelling + map membership), not whether any + * row actually carries the field — data-aware refusals (the did-you-mean + * for a bare system-scalar name no row carries) belong to the query layer, + * which calls {@link buildUnresolvableMessage} with index knowledge. + * + * @param raw - The field name as the caller wrote it (`level`, + * `metadata.level`, `system.createdAt`, …) + * @param kind - Entity or relation resolution (selects the system map) + * @returns The parsed {@link FieldAddress} + * @throws {InvalidFieldAddressError} for a `system.*` name outside the ruled + * map (including every plumbing field) or a malformed spelling — the error + * text enumerates the valid system scalars so the fix is in the message. + * + * @example + * parseFieldAddress('level', 'entity') // { scope: 'metadata', field: 'level' } + * parseFieldAddress('metadata.level', 'entity') // { scope: 'metadata', field: 'level' } + * parseFieldAddress('system.createdAt', 'entity') // { scope: 'system', field: 'createdAt' } + * parseFieldAddress('system.level', 'entity') // throws — plumbing is invisible + */ +export function parseFieldAddress( + raw: string, + kind: FieldAddressKind +): FieldAddress { + const systemMap = + kind === 'entity' ? SYSTEM_ENTITY_SCALARS : SYSTEM_RELATION_SCALARS + + if (raw.startsWith('system.')) { + const field = raw.slice('system.'.length) + if (!systemMap.has(field)) { + throw new InvalidFieldAddressError(raw, kind, systemMap) + } + return { scope: 'system', field, raw } + } + + if (raw.startsWith('metadata.')) { + const field = raw.slice('metadata.'.length) + if (field.length === 0) { + throw new InvalidFieldAddressError(raw, kind, systemMap) + } + return { scope: 'metadata', field, raw } + } + + if (raw.length === 0) { + throw new InvalidFieldAddressError(raw, kind, systemMap) + } + + // Bare name = the user's metadata field. Always. Even when the same name + // exists in the system map — `confidence` as a bare name is the user's + // metadata field named confidence; the engine scalar is system.confidence. + return { scope: 'metadata', field: raw, raw } +} + +/** + * Read the addressed value off an entity. The ONLY sanctioned way a query + * surface turns a {@link FieldAddress} into a value — direct property reads + * against records re-create the shadow class this module exists to kill. + * + * @returns The value, or `undefined` when the record does not carry it + * (missing values sort LAST in both directions per the ordering contract — + * they are never grounds for dropping a row). + */ +export function readEntityFieldAddress( + entity: HNSWNounWithMetadata, + address: FieldAddress +): unknown { + if (address.scope === 'system') { + return (entity as unknown as Record)[address.field] + } + return entity.metadata?.[address.field] +} + +/** + * Relation twin of {@link readEntityFieldAddress}. The stored flat record + * keys the relation type under `verb`; public Relation shapes may carry it + * as `type` — both spellings of the record are read, the ADDRESS is always + * `system.verb`. + */ +export function readRelationFieldAddress( + verb: HNSWVerbWithMetadata, + address: FieldAddress +): unknown { + if (address.scope === 'system') { + const rec = verb as unknown as Record + if (address.field === 'verb') return rec.verb ?? rec.type + return rec[address.field] + } + return verb.metadata?.[address.field] +} + +/** + * Build the ruled did-you-mean refusal text for a bare name that resolved to + * metadata but is UNKNOWN to the index — the data-aware half of the law, + * called by the query layer once it has consulted the known-field set: + * + * "no metadata field 'createdAt' — did you mean system.createdAt or + * metadata.createdAt?" + * + * When the bare name is NOT a system scalar the system candidate is omitted + * (there is only one thing the caller could have meant; the refusal exists + * because refusing beats silently sorting nothing). + */ +export function buildUnresolvableMessage( + raw: string, + kind: FieldAddressKind +): string { + const systemMap = + kind === 'entity' ? SYSTEM_ENTITY_SCALARS : SYSTEM_RELATION_SCALARS + if (systemMap.has(raw)) { + return ( + `no metadata field '${raw}' — did you mean system.${raw} or metadata.${raw}? ` + + `(bare names always address your metadata; engine fields need the system. prefix)` + ) + } + return ( + `no metadata field '${raw}' on this store — nothing carries it, so an ordered or ` + + `filtered read against it cannot mean anything. Spell it metadata.${raw} once the ` + + `field exists, or check the field name.` + ) +} + +/** + * @description Refusal for a malformed or out-of-map field ADDRESS — + * `system.` (including all plumbing), an empty + * name, or a bare `metadata.` prefix. The message carries the full valid + * system map so the fix never needs a docs lookup. + */ +export class InvalidFieldAddressError extends Error { + public readonly raw: string + public readonly kind: FieldAddressKind + + constructor(raw: string, kind: FieldAddressKind, systemMap: ReadonlySet) { + const valid = [...systemMap].map((f) => `system.${f}`).join(', ') + super( + `'${raw}' is not an addressable ${kind} field. Bare names address your own ` + + `metadata fields; engine fields are exactly: ${valid}. Engine plumbing ` + + `(vector, connections, level, data, _rev) is not part of the query surface.` + ) + this.name = 'InvalidFieldAddressError' + this.raw = raw + this.kind = kind + } +}