From c2fb28a2f7c261dd055677b6042803e2afd8de3d Mon Sep 17 00:00:00 2001 From: David Snelling Date: Mon, 3 Aug 2026 15:51:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(namespace):=20egress=20guard=20+=20validat?= =?UTF-8?q?ion=20speak=20the=20law=20=E2=80=94=20whereMatcher's=20resolver?= =?UTF-8?q?=20reads=20system.*=20from=20the=20record=20and=20bare=20names?= =?UTF-8?q?=20from=20the=20metadata=20bag=20only=20(the=20bare-system=20sw?= =?UTF-8?q?itch=20is=20dead);=20validateFindParams=20refuses=20cursor/incl?= =?UTF-8?q?udeRelations/writeOnly=20typed=20(accepted-and-ignored=20dies?= =?UTF-8?q?=20as=20a=20class),=20validates=20order,=20and=20parses=20every?= =?UTF-8?q?=20orderBy=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/db/fieldAddressing.ts | 39 ++++++++++++++++++++ src/db/whereMatcher.ts | 69 +++++++++++++++++++----------------- src/utils/paramValidation.ts | 29 +++++++++++++-- 3 files changed, 101 insertions(+), 36 deletions(-) diff --git a/src/db/fieldAddressing.ts b/src/db/fieldAddressing.ts index 0ee09a05..cd63871c 100644 --- a/src/db/fieldAddressing.ts +++ b/src/db/fieldAddressing.ts @@ -244,3 +244,42 @@ export class InvalidFieldAddressError extends Error { this.kind = kind } } + +/** + * @description Refusal for a syntactically valid address that resolves to + * NOTHING — a bare name no user field carries. Carries the did-you-mean + * (both candidate spellings when the name collides with a system scalar) so + * the fix ships inside the error. Thrown by the query layer with index + * knowledge, never by the pure parser. + */ +export class UnresolvableFieldError extends Error { + public readonly raw: string + public readonly kind: FieldAddressKind + + constructor(raw: string, kind: FieldAddressKind) { + super(buildUnresolvableMessage(raw, kind)) + this.name = 'UnresolvableFieldError' + this.raw = raw + this.kind = kind + } +} + +/** + * @description Refusal for a find() option that is accepted by the type + * surface but NOT implemented — an accepted option must work or refuse; + * accepted-and-ignored died as a class (sealed 2026-08-03). Names the + * option and the honest state so nobody discovers a no-op by measurement. + */ +export class UnsupportedFindOptionError extends Error { + public readonly option: string + + constructor(option: string) { + super( + `find() option '${option}' is not implemented — it used to be silently ` + + `ignored, which read as working. Remove it from the call (or track the ` + + `feature request); it will be honored or refused, never swallowed.` + ) + this.name = 'UnsupportedFindOptionError' + this.option = option + } +} diff --git a/src/db/whereMatcher.ts b/src/db/whereMatcher.ts index c5469209..8dab02fd 100644 --- a/src/db/whereMatcher.ts +++ b/src/db/whereMatcher.ts @@ -61,41 +61,44 @@ export class UnsupportedWhereOperatorError extends Error { * @returns The field's value, or `undefined` when absent. */ export function resolveEntityField(entity: Entity, field: string): unknown { - switch (field) { - case 'noun': - case 'type': - return entity.type - case 'subtype': - return entity.subtype - case 'id': - return entity.id - case 'createdAt': - return entity.createdAt - case 'updatedAt': - return entity.updatedAt - case 'service': - return entity.service - case 'createdBy': - return entity.createdBy - case 'confidence': - return entity.confidence - case 'weight': - return entity.weight - case '_rev': - return entity._rev - case 'data': - return entity.data + // THE ONE ADDRESSING LAW (sealed 2026-08-03): `system.` reads the + // entity scalar; bare and `metadata.`-prefixed names read the user's + // metadata bag (dotted paths traverse INSIDE the bag). The old bare-name + // switch over system fields is dead — bare `createdAt` is the user's own + // field now; the engine scalar is `system.createdAt`. Plumbing (vector, + // connections, level, data, _rev) is invisible: no spelling reaches it. + if (field.startsWith('system.')) { + switch (field.slice('system.'.length)) { + case 'type': + return entity.type + case 'subtype': + return entity.subtype + case 'id': + return entity.id + case 'createdAt': + return entity.createdAt + case 'updatedAt': + return entity.updatedAt + case 'service': + return entity.service + case 'createdBy': + return entity.createdBy + case 'confidence': + return entity.confidence + case 'weight': + return entity.weight + case 'visibility': + return (entity as unknown as Record).visibility + } + // Out-of-map system spelling: parse refuses these upstream with a typed + // error; reaching here (internal callers only) reads as absent. + return undefined } - if (field.includes('.')) { - // Dotted path: resolve against the whole entity first (`metadata.x`), - // then against the metadata bag (`address.city` on nested metadata). - const fromEntity = resolvePath(entity as unknown as Record, field) - if (fromEntity !== undefined) return fromEntity - return resolvePath((entity.metadata ?? {}) as Record, field) - } - - return ((entity.metadata ?? {}) as Record)[field] + const path = field.startsWith('metadata.') ? field.slice('metadata.'.length) : field + const bag = (entity.metadata ?? {}) as Record + if (!path.includes('.')) return bag[path] + return resolvePath(bag, path) } /** Walk a dotted path through nested plain objects. */ diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts index ca439524..fd018a04 100644 --- a/src/utils/paramValidation.ts +++ b/src/utils/paramValidation.ts @@ -17,6 +17,7 @@ import { findCallerLocation } from './callerLocation.js' // fallback branches that no supported runtime can reach. import * as os from 'node:os' import * as fs from 'node:fs' +import { parseFieldAddress, UnsupportedFindOptionError } from '../db/fieldAddressing.js' const getSystemMemory = (): number => { if (os) { @@ -466,9 +467,31 @@ export function validateFindParams(params: FindParams): void { throw new Error('cannot specify both query and vector - they are mutually exclusive') } - // Universal truth: can't use both cursor and offset pagination - if (params.cursor !== undefined && params.offset !== undefined) { - throw new Error('cannot use both cursor and offset pagination simultaneously') + // ACCEPTED-AND-IGNORED DIED AS A CLASS (sealed 2026-08-03): options the + // engine does not implement REFUSE with a typed error instead of silently + // doing nothing — a production consumer discovered a no-op by measurement + // once; never again. + if (params.cursor !== undefined) { + throw new UnsupportedFindOptionError('cursor') + } + if ((params as Record).includeRelations !== undefined) { + throw new UnsupportedFindOptionError('includeRelations') + } + if ((params as Record).writeOnly !== undefined) { + throw new UnsupportedFindOptionError('writeOnly') + } + + // THE ONE ADDRESSING LAW: the orderBy address must PARSE (bare/metadata. = + // user field, system. = the ruled map, anything else refuses typed + // with the valid map in the message) and order must be a real direction. + if (params.orderBy !== undefined) { + if (typeof params.orderBy !== 'string') { + throw new Error('orderBy must be a string field address') + } + parseFieldAddress(params.orderBy, 'entity') // throws InvalidFieldAddressError on a bad address + } + if (params.order !== undefined && params.order !== 'asc' && params.order !== 'desc') { + throw new Error(`order must be 'asc' or 'desc', got '${String(params.order)}'`) } // Auto-limit query length based on memory