feat(namespace): egress guard + validation speak the law — whereMatcher's resolver reads system.* from the record and bare names from the metadata bag only (the bare-system switch is dead); validateFindParams refuses cursor/includeRelations/writeOnly typed (accepted-and-ignored dies as a class), validates order, and parses every orderBy address
This commit is contained in:
parent
4679c89458
commit
c2fb28a2f7
3 changed files with 101 additions and 36 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.<field>` 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<string, unknown>).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<string, unknown>, field)
|
||||
if (fromEntity !== undefined) return fromEntity
|
||||
return resolvePath((entity.metadata ?? {}) as Record<string, unknown>, field)
|
||||
}
|
||||
|
||||
return ((entity.metadata ?? {}) as Record<string, unknown>)[field]
|
||||
const path = field.startsWith('metadata.') ? field.slice('metadata.'.length) : field
|
||||
const bag = (entity.metadata ?? {}) as Record<string, unknown>
|
||||
if (!path.includes('.')) return bag[path]
|
||||
return resolvePath(bag, path)
|
||||
}
|
||||
|
||||
/** Walk a dotted path through nested plain objects. */
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>).includeRelations !== undefined) {
|
||||
throw new UnsupportedFindOptionError('includeRelations')
|
||||
}
|
||||
if ((params as Record<string, unknown>).writeOnly !== undefined) {
|
||||
throw new UnsupportedFindOptionError('writeOnly')
|
||||
}
|
||||
|
||||
// THE ONE ADDRESSING LAW: the orderBy address must PARSE (bare/metadata. =
|
||||
// user field, system.<field> = 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue