test(namespace)+docs: the cross-engine conformance suite (self-arming — skips until the resolver exports land) + the public field-addressing docs page; sidebar order deconflicted to 7
This commit is contained in:
parent
8f9a9989e9
commit
d8d0b55f9d
2 changed files with 680 additions and 0 deletions
196
docs/concepts/field-addressing.md
Normal file
196
docs/concepts/field-addressing.md
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
---
|
||||||
|
title: Field addressing: your fields and system fields
|
||||||
|
slug: concepts/field-addressing
|
||||||
|
public: true
|
||||||
|
category: concepts
|
||||||
|
template: concept
|
||||||
|
order: 7
|
||||||
|
description: The one rule for every query-surface field name — a bare name always means your metadata, system.<field> reaches the ten engine scalars explicitly, and anything else refuses by name.
|
||||||
|
next:
|
||||||
|
- concepts/consistency-model
|
||||||
|
---
|
||||||
|
|
||||||
|
# Field addressing: your fields and system fields
|
||||||
|
|
||||||
|
Every query surface in Brainy — `find()`'s `where`, `orderBy`, aggregation
|
||||||
|
`groupBy`, and aggregation `source.where` — resolves field names by one rule,
|
||||||
|
with no exceptions:
|
||||||
|
|
||||||
|
> **A bare field name always means your metadata. `system.<field>` reaches an
|
||||||
|
> engine scalar, and only when you spell it explicitly.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await brain.find({ orderBy: 'level' }) // reads entity.metadata.level — YOUR field
|
||||||
|
await brain.find({ orderBy: 'system.createdAt' }) // reads the engine's createdAt scalar
|
||||||
|
await brain.find({ orderBy: 'metadata.level' }) // identical to bare 'level' — explicit scope
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no priority list, no "try the system field, fall back to metadata"
|
||||||
|
behavior, and no name that resolves differently depending on what else
|
||||||
|
happens to exist on your entities. A field called `level`, `score`,
|
||||||
|
`createdAt`, or `type` in your own `metadata` is read as *your* field, every
|
||||||
|
time, by its bare name.
|
||||||
|
|
||||||
|
## Why this rule exists
|
||||||
|
|
||||||
|
An internal report from a production deployment found that a user metadata
|
||||||
|
field literally named `level` was being silently shadowed by the engine's
|
||||||
|
own internal index layer field of the same name — every sort by `level`
|
||||||
|
returned insertion order, with no error raised. This rule makes that class of
|
||||||
|
bug structurally impossible: bare names belong to you, unconditionally, and
|
||||||
|
anything that isn't yours has to be spelled out.
|
||||||
|
|
||||||
|
## The system scalars
|
||||||
|
|
||||||
|
`system.<field>` addresses exactly ten scalars on an entity — no more, no
|
||||||
|
fewer:
|
||||||
|
|
||||||
|
| System field | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `system.id` | The entity's id |
|
||||||
|
| `system.type` | The entity's `NounType` |
|
||||||
|
| `system.subtype` | The per-app sub-classification passed to `add()` |
|
||||||
|
| `system.createdAt` | When the entity was created |
|
||||||
|
| `system.updatedAt` | When the entity was last written |
|
||||||
|
| `system.confidence` | The `confidence` param (0–1) |
|
||||||
|
| `system.weight` | The `weight` param |
|
||||||
|
| `system.visibility` | `'public'` / `'internal'` (see the visibility tiers in [Consistency Model](./consistency-model.md)) |
|
||||||
|
| `system.service` | The multi-tenancy `service` tag |
|
||||||
|
| `system.createdBy` | Who/what created the entity |
|
||||||
|
|
||||||
|
Relationships mirror the same eight shared scalars (`subtype`, `createdAt`,
|
||||||
|
`updatedAt`, `confidence`, `weight`, `visibility`, `service`, `createdBy`)
|
||||||
|
plus three of their own:
|
||||||
|
|
||||||
|
| System field (relationship) | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `system.verb` | The relationship's `VerbType` |
|
||||||
|
| `system.sourceId` | The id of the entity the relationship starts from |
|
||||||
|
| `system.targetId` | The id of the entity the relationship points to |
|
||||||
|
|
||||||
|
Anything not on these two lists is not a system scalar — `system.<name>` for
|
||||||
|
any other name refuses (see "Refusal semantics" below), even if that name
|
||||||
|
sounds like it should be engine-owned.
|
||||||
|
|
||||||
|
## Invisible plumbing — never addressable, in either spelling
|
||||||
|
|
||||||
|
Five names are pure engine internals. They are not reachable as a bare name,
|
||||||
|
and not reachable as `system.<name>` either — they simply have no place on
|
||||||
|
the query surface:
|
||||||
|
|
||||||
|
- **`vector`** — the stored embedding. It participates in similarity search
|
||||||
|
(`query`, `near`, vector `find()`), never in `where`/`orderBy`/`groupBy`.
|
||||||
|
- **`connections`** — graph adjacency. Reached through `connected` and
|
||||||
|
`brain.related()`, not through field addressing.
|
||||||
|
- **`level`** — the internal index layer number used by the nearest-neighbor
|
||||||
|
graph. It is pure index plumbing with no query-surface meaning at all —
|
||||||
|
which is exactly why a user field of the same name must never be shadowed
|
||||||
|
by it. `level` as a bare name is always yours; there is no engine-owned
|
||||||
|
spelling of it to compete with.
|
||||||
|
- **`data`** — your entity's content payload, not a scalar. It can be a
|
||||||
|
string, a number, or an arbitrary object, so sorting or filtering it as a
|
||||||
|
single comparable value would lie about its actual shape. Content is
|
||||||
|
reached through the content/text-search APIs (`query`, `searchMode:
|
||||||
|
'text'`), not through `where`/`orderBy`.
|
||||||
|
- **`_rev`** — the per-entity revision counter used for optimistic
|
||||||
|
concurrency (`ifRev`). It is a CAS token, not a queryable dimension.
|
||||||
|
|
||||||
|
`system.level`, `system.vector`, and `system.data` all refuse for the same
|
||||||
|
reason: they are not in the ten-scalar system map, full stop.
|
||||||
|
|
||||||
|
## `metadata.<field>` — the explicit spelling of "mine"
|
||||||
|
|
||||||
|
Prefix any field with `metadata.` to say the same thing a bare name already
|
||||||
|
says, spelled out. The two are interchangeable everywhere a field name is
|
||||||
|
accepted, including `orderBy`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await brain.find({ where: { 'customer.tier': 'gold' } })
|
||||||
|
await brain.find({ where: { 'metadata.customer.tier': 'gold' } }) // identical
|
||||||
|
await brain.find({ orderBy: 'metadata.score', order: 'desc' }) // identical to orderBy: 'score'
|
||||||
|
```
|
||||||
|
|
||||||
|
Reach for the explicit spelling when it reads more clearly next to a
|
||||||
|
`system.` field in the same query — for example, sorting by your own `score`
|
||||||
|
while filtering on `system.confidence`.
|
||||||
|
|
||||||
|
## Refusal semantics
|
||||||
|
|
||||||
|
A name that resolves to neither your metadata nor a system scalar is a typed
|
||||||
|
refusal, not a silent empty result and not a guess. Refusals name **both**
|
||||||
|
candidates, so the fix is always in the error text:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await brain.find({ orderBy: 'createdAt' })
|
||||||
|
// UnresolvableFieldError: no metadata field 'createdAt' — did you mean
|
||||||
|
// system.createdAt or metadata.createdAt?
|
||||||
|
```
|
||||||
|
|
||||||
|
`UnresolvableFieldError` is exported from the package root:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { UnresolvableFieldError } from '@soulcraft/brainy'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await brain.find({ orderBy: 'createdAt' })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnresolvableFieldError) {
|
||||||
|
// err.message names both candidates — usually enough to fix the call site.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A handful of `find()` options are not implemented yet: `cursor`,
|
||||||
|
`includeRelations`, and `writeOnly`. Rather than accepting them and quietly
|
||||||
|
ignoring the option, `find()` refuses with `UnsupportedFindOptionError` —
|
||||||
|
also exported from the package root — so a call site can never believe an
|
||||||
|
unimplemented option took effect when it didn't.
|
||||||
|
|
||||||
|
## The ordering contract
|
||||||
|
|
||||||
|
`orderBy` behaves identically regardless of which engine (the pure-TypeScript
|
||||||
|
path or a native accelerator) is serving the query:
|
||||||
|
|
||||||
|
- An entity missing the `orderBy` field, or holding `null` on it, sorts
|
||||||
|
**LAST — in both `asc` and `desc`**. It is never treated as "smaller than
|
||||||
|
everything" in one direction and "larger than everything" in the other; it
|
||||||
|
is simply last, either way.
|
||||||
|
- Rows are **never dropped** from an ordered read because they lack the
|
||||||
|
field — a missing value changes position, never presence.
|
||||||
|
- Ties on the `orderBy` field break by **id ascending**, regardless of the
|
||||||
|
primary sort direction.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// employees: [{ score: 9 }, { score: 5 }, { /* no score field */ }]
|
||||||
|
await brain.find({ orderBy: 'score', order: 'desc' }) // [9, 5, missing] — missing is last
|
||||||
|
await brain.find({ orderBy: 'score', order: 'asc' }) // [5, 9, missing] — missing is STILL last
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrating existing call sites
|
||||||
|
|
||||||
|
If you have call sites written before this rule shipped that rely on a bare
|
||||||
|
system name — `orderBy: 'createdAt'`, `where: { confidence: { greaterThan:
|
||||||
|
0.8 } }`, and similar — they now refuse instead of silently resolving to the
|
||||||
|
engine field. The fix is always in the error: swap the bare name for
|
||||||
|
`system.<field>` (or `metadata.<field>` if you actually meant your own field
|
||||||
|
of that name, and it happens to share a name with a system scalar):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before: bare 'createdAt' silently meant the engine's timestamp.
|
||||||
|
await brain.find({ orderBy: 'createdAt' })
|
||||||
|
|
||||||
|
// After: say which one you meant.
|
||||||
|
await brain.find({ orderBy: 'system.createdAt' }) // the engine timestamp
|
||||||
|
await brain.find({ orderBy: 'metadata.createdAt' }) // your own field named createdAt, if you have one
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no silent migration path by design — every ambiguous call site
|
||||||
|
surfaces as a refusal naming its own fix, once, the first time it runs
|
||||||
|
against the new rule.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
- [Consistency Model](./consistency-model.md) — the separate (and
|
||||||
|
longer-standing) contract for *reserved* fields: which names may never
|
||||||
|
appear inside a `metadata` bag at write time, distinct from this page's
|
||||||
|
read-time addressing rule.
|
||||||
484
tests/conformance/namespace-law.test.ts
Normal file
484
tests/conformance/namespace-law.test.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
/**
|
||||||
|
* @module tests/conformance/namespace-law
|
||||||
|
* @description Conformance suite for the ruled field-addressing contract
|
||||||
|
* announced in RELEASES.md ("Coming next... one field-addressing law — bare
|
||||||
|
* names = user metadata, `system.<field>` for engine fields, typed refusals
|
||||||
|
* for unresolvable names"). This suite is the drift-proof shared by this
|
||||||
|
* engine and its native accelerator: both must satisfy every test here
|
||||||
|
* bit-for-bit, because they implement the SAME contract independently.
|
||||||
|
*
|
||||||
|
* The rule, in full:
|
||||||
|
* 1. A bare field name in `where` / `orderBy` / `groupBy` / aggregation
|
||||||
|
* `source.where` ALWAYS means the caller's own `metadata` field. No
|
||||||
|
* priority resolution, no engine fallback — ever.
|
||||||
|
* 2. `system.<field>` reaches an engine scalar, and ONLY an engine scalar,
|
||||||
|
* and ONLY when spelled explicitly. The addressable entity map is exactly
|
||||||
|
* ten names: id, type, subtype, createdAt, updatedAt, confidence, weight,
|
||||||
|
* visibility, service, createdBy. The relationship map is system.verb,
|
||||||
|
* system.sourceId, system.targetId, plus the eight scalars shared with
|
||||||
|
* entities.
|
||||||
|
* 3. Some names are invisible plumbing and are never addressable in either
|
||||||
|
* spelling: vector, connections, level, data, _rev. `system.level`,
|
||||||
|
* `system.vector`, and `system.data` all refuse — they are not in the
|
||||||
|
* system map. Bare `level` is a perfectly ordinary user field.
|
||||||
|
* 4. `metadata.<field>` is the explicit-user-scope spelling: identical
|
||||||
|
* semantics to the bare spelling, valid everywhere the bare spelling is.
|
||||||
|
* 5. Anything that resolves to neither a user field nor a system scalar is a
|
||||||
|
* typed refusal naming both candidates (`UnresolvableFieldError`).
|
||||||
|
* Unimplemented `find()` options (`cursor`, `includeRelations`,
|
||||||
|
* `writeOnly`) refuse with `UnsupportedFindOptionError` instead of being
|
||||||
|
* silently accepted and ignored.
|
||||||
|
* 6. Ordering is identical on both engines: rows missing/null on the
|
||||||
|
* `orderBy` field sort LAST in BOTH directions and are never dropped;
|
||||||
|
* ties break by id ascending.
|
||||||
|
*
|
||||||
|
* The motivating incident (told generically — see CLAUDE.md naming rule): an
|
||||||
|
* internal report from a production deployment showed a user metadata field
|
||||||
|
* literally named `level` silently shadowed by the engine's internal HNSW
|
||||||
|
* node layer, breaking sort order with zero errors raised. This contract
|
||||||
|
* makes that class of bug impossible, and testable forever.
|
||||||
|
*
|
||||||
|
* SELF-SKIP: the resolver this suite pins is being built in a parallel
|
||||||
|
* session and has not landed on every branch yet. Rather than going red on
|
||||||
|
* a branch that simply hasn't caught up, the suite detects whether the
|
||||||
|
* contract is live by the one thing any conformant implementation must
|
||||||
|
* export — `UnresolvableFieldError` from the package root — and skips
|
||||||
|
* loudly (never silently) until it does. This is the house pattern: a
|
||||||
|
* sibling engine's gate once went red because a test armed before its
|
||||||
|
* feature existed.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { Brainy } from '../../src/brainy.js'
|
||||||
|
import { NounType } from '../../src/types/graphTypes.js'
|
||||||
|
import * as brainyExports from '../../src/index.js'
|
||||||
|
|
||||||
|
const stubEmbedding = async (text: string): Promise<number[]> => {
|
||||||
|
const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||||
|
return new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detected purely by the exported error-class NAME — never by reaching into
|
||||||
|
// implementation internals. Both engines building this contract must export
|
||||||
|
// it from the package root, so this is a legitimate, implementation-agnostic
|
||||||
|
// readiness probe.
|
||||||
|
const lawActive = 'UnresolvableFieldError' in brainyExports
|
||||||
|
const UnresolvableFieldError = (brainyExports as Record<string, unknown>).UnresolvableFieldError as new (
|
||||||
|
...args: any[]
|
||||||
|
) => Error
|
||||||
|
const UnsupportedFindOptionError = (brainyExports as Record<string, unknown>)
|
||||||
|
.UnsupportedFindOptionError as new (...args: any[]) => Error
|
||||||
|
|
||||||
|
// Always runs, regardless of lawActive — the loud signal that the rest of
|
||||||
|
// this file was skipped, and why.
|
||||||
|
it('namespace law armed?', () => {
|
||||||
|
if (!lawActive) {
|
||||||
|
console.warn(
|
||||||
|
'[conformance] namespace-law suite SKIPPED — UnresolvableFieldError not exported yet; arms when the resolver lands'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Awaits `promise`, asserting it rejects with an instance of `ErrorClass`
|
||||||
|
* whose `.message` contains every string in `mustContain`. Fails loudly if
|
||||||
|
* the promise resolves instead of rejecting.
|
||||||
|
*/
|
||||||
|
async function expectRefusal(
|
||||||
|
promise: Promise<unknown>,
|
||||||
|
ErrorClass: new (...args: any[]) => Error,
|
||||||
|
...mustContain: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
await promise
|
||||||
|
} catch (err) {
|
||||||
|
threw = true
|
||||||
|
expect(err).toBeInstanceOf(ErrorClass)
|
||||||
|
for (const fragment of mustContain) {
|
||||||
|
expect((err as Error).message).toContain(fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(threw).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skipIf(!lawActive)('namespace law — bare/system/metadata field addressing', () => {
|
||||||
|
let brain: Brainy
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
brain = new Brainy({
|
||||||
|
requireSubtype: false,
|
||||||
|
storage: { type: 'memory' as const },
|
||||||
|
embeddingFunction: stubEmbedding
|
||||||
|
})
|
||||||
|
await brain.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await brain.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The star case from the motivating incident: metadata.level 3/9/6. */
|
||||||
|
async function addLevelRows(): Promise<string[]> {
|
||||||
|
const ids: string[] = []
|
||||||
|
for (const level of [3, 9, 6]) {
|
||||||
|
ids.push(
|
||||||
|
await brain.add({
|
||||||
|
data: `probe level ${level}`,
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-level',
|
||||||
|
metadata: { name: `p-${level}`, level }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 1 — bare field name = the user's metadata field, always.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("bare orderBy 'level' reads user metadata, desc and asc (the star case)", async () => {
|
||||||
|
await addLevelRows()
|
||||||
|
|
||||||
|
const desc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-level',
|
||||||
|
orderBy: 'level',
|
||||||
|
order: 'desc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3])
|
||||||
|
|
||||||
|
const asc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-level',
|
||||||
|
orderBy: 'level',
|
||||||
|
order: 'asc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(asc.map((r: any) => r.metadata?.level)).toEqual([3, 6, 9])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("bare where { level: N } matches the user's field", async () => {
|
||||||
|
const ids = await addLevelRows()
|
||||||
|
const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-level', where: { level: 9 } })
|
||||||
|
expect(hit).toHaveLength(1)
|
||||||
|
expect(hit[0].id).toBe(ids[1])
|
||||||
|
expect(hit[0].metadata?.level).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 4 — metadata.<field> is the explicit-user-scope spelling,
|
||||||
|
// identical semantics to bare, valid on every path including orderBy.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("'metadata.level' resolves identically to bare 'level'", async () => {
|
||||||
|
await addLevelRows()
|
||||||
|
const desc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-level',
|
||||||
|
orderBy: 'metadata.level',
|
||||||
|
order: 'desc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(desc.map((r: any) => r.metadata?.level)).toEqual([9, 6, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 2 — system.<field> reaches an engine scalar explicitly.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('system.createdAt sorts by entity age', async () => {
|
||||||
|
const ids: string[] = []
|
||||||
|
for (const name of ['first', 'second', 'third']) {
|
||||||
|
ids.push(
|
||||||
|
await brain.add({
|
||||||
|
data: `aged ${name}`,
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-aged',
|
||||||
|
metadata: { name }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
// Guarantee distinct createdAt timestamps between adds.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
const asc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-aged',
|
||||||
|
orderBy: 'system.createdAt',
|
||||||
|
order: 'asc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(asc.map((r: any) => r.id)).toEqual(ids)
|
||||||
|
|
||||||
|
const desc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-aged',
|
||||||
|
orderBy: 'system.createdAt',
|
||||||
|
order: 'desc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(desc.map((r: any) => r.id)).toEqual([...ids].reverse())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('where on system.confidence filters by the engine scalar', async () => {
|
||||||
|
const highId = await brain.add({
|
||||||
|
data: 'high confidence row',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-confidence',
|
||||||
|
confidence: 0.95,
|
||||||
|
metadata: { name: 'hi' }
|
||||||
|
})
|
||||||
|
await brain.add({
|
||||||
|
data: 'low confidence row',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-confidence',
|
||||||
|
confidence: 0.4,
|
||||||
|
metadata: { name: 'lo' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const hit = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-confidence',
|
||||||
|
where: { 'system.confidence': 0.95 }
|
||||||
|
})
|
||||||
|
expect(hit).toHaveLength(1)
|
||||||
|
expect(hit[0].id).toBe(highId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groupBy on system.subtype groups by the engine scalar, not user metadata', async () => {
|
||||||
|
await brain.add({ data: 'i1', type: NounType.Document, subtype: 'invoice' })
|
||||||
|
await brain.add({ data: 'i2', type: NounType.Document, subtype: 'invoice' })
|
||||||
|
await brain.add({ data: 'r1', type: NounType.Document, subtype: 'receipt' })
|
||||||
|
|
||||||
|
brain.defineAggregate({
|
||||||
|
name: 'ns_law_by_subtype_system',
|
||||||
|
source: { type: NounType.Document },
|
||||||
|
groupBy: ['system.subtype'],
|
||||||
|
metrics: { count: { op: 'count' } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = await brain.queryAggregate('ns_law_by_subtype_system')
|
||||||
|
const invoiceGroup = groups.find((g) => Object.values(g.groupKey).includes('invoice'))
|
||||||
|
const receiptGroup = groups.find((g) => Object.values(g.groupKey).includes('receipt'))
|
||||||
|
expect(invoiceGroup?.metrics.count).toBe(2)
|
||||||
|
expect(receiptGroup?.metrics.count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 1 (groupBy face) — bare groupBy dimensions read user metadata,
|
||||||
|
// never the engine's own notion of the same-sounding name.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('groupBy on a bare user metadata field groups by that field', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'd1',
|
||||||
|
type: NounType.Document,
|
||||||
|
subtype: 'ns-law-group-bare',
|
||||||
|
metadata: { team: 'alpha' }
|
||||||
|
})
|
||||||
|
await brain.add({
|
||||||
|
data: 'd2',
|
||||||
|
type: NounType.Document,
|
||||||
|
subtype: 'ns-law-group-bare',
|
||||||
|
metadata: { team: 'alpha' }
|
||||||
|
})
|
||||||
|
await brain.add({
|
||||||
|
data: 'd3',
|
||||||
|
type: NounType.Document,
|
||||||
|
subtype: 'ns-law-group-bare',
|
||||||
|
metadata: { team: 'beta' }
|
||||||
|
})
|
||||||
|
|
||||||
|
brain.defineAggregate({
|
||||||
|
name: 'ns_law_by_team_bare',
|
||||||
|
source: { type: NounType.Document, where: { subtype: 'ns-law-group-bare' } },
|
||||||
|
groupBy: ['team'],
|
||||||
|
metrics: { count: { op: 'count' } }
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = await brain.queryAggregate('ns_law_by_team_bare')
|
||||||
|
const alphaGroup = groups.find((g) => Object.values(g.groupKey).includes('alpha'))
|
||||||
|
const betaGroup = groups.find((g) => Object.values(g.groupKey).includes('beta'))
|
||||||
|
expect(alphaGroup?.metrics.count).toBe(2)
|
||||||
|
expect(betaGroup?.metrics.count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('where on a bare user metadata field filters normally (score, not a system name)', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'high score',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-score',
|
||||||
|
metadata: { score: 42 }
|
||||||
|
})
|
||||||
|
await brain.add({
|
||||||
|
data: 'low score',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-score',
|
||||||
|
metadata: { score: 7 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const hit = await brain.find({ type: NounType.Person, subtype: 'ns-law-score', where: { score: 42 } })
|
||||||
|
expect(hit).toHaveLength(1)
|
||||||
|
expect(hit[0].metadata?.score).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 5 — typed refusals, naming both candidates.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it("bare orderBy 'createdAt' refuses when no such metadata field exists — names both candidates", async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'no metadata.createdAt here',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-refuse-createdAt',
|
||||||
|
metadata: { name: 'x' }
|
||||||
|
})
|
||||||
|
|
||||||
|
await expectRefusal(
|
||||||
|
brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-refuse-createdAt',
|
||||||
|
orderBy: 'createdAt',
|
||||||
|
limit: 10
|
||||||
|
}),
|
||||||
|
UnresolvableFieldError,
|
||||||
|
'system.createdAt',
|
||||||
|
'metadata.createdAt'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 3 — invisible plumbing refuses in either spelling; system.<name>
|
||||||
|
// for a name that isn't in the ten-scalar map is unresolvable.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('system.level refuses — level is invisible plumbing, never a system scalar', async () => {
|
||||||
|
await brain.add({
|
||||||
|
data: 'has a level metadata field',
|
||||||
|
type: NounType.Person,
|
||||||
|
metadata: { level: 5 }
|
||||||
|
})
|
||||||
|
await expectRefusal(brain.find({ orderBy: 'system.level', limit: 10 }), UnresolvableFieldError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('system.vector refuses — vector is invisible plumbing, never a system scalar', async () => {
|
||||||
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
||||||
|
await expectRefusal(brain.find({ orderBy: 'system.vector', limit: 10 }), UnresolvableFieldError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('system.data refuses — data is a payload container, never a system scalar', async () => {
|
||||||
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
||||||
|
await expectRefusal(brain.find({ orderBy: 'system.data', limit: 10 }), UnresolvableFieldError)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 6 — the ordering contract.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function addOrderingProbeRows(): Promise<{ ranked: string[]; missing: string }> {
|
||||||
|
const low = await brain.add({
|
||||||
|
data: 'low score',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ordering',
|
||||||
|
metadata: { score: 5 }
|
||||||
|
})
|
||||||
|
const high = await brain.add({
|
||||||
|
data: 'high score',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ordering',
|
||||||
|
metadata: { score: 9 }
|
||||||
|
})
|
||||||
|
const missing = await brain.add({
|
||||||
|
data: 'no score field at all',
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ordering',
|
||||||
|
metadata: { name: 'no-score' }
|
||||||
|
})
|
||||||
|
return { ranked: [low, high], missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('a row missing the orderBy field sorts LAST in desc — and is never dropped', async () => {
|
||||||
|
const { ranked, missing } = await addOrderingProbeRows()
|
||||||
|
const desc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ordering',
|
||||||
|
orderBy: 'score',
|
||||||
|
order: 'desc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(desc).toHaveLength(3)
|
||||||
|
expect(desc.map((r: any) => r.id)).toEqual([ranked[1], ranked[0], missing])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a row missing the orderBy field sorts LAST in asc too — and is never dropped', async () => {
|
||||||
|
const { ranked, missing } = await addOrderingProbeRows()
|
||||||
|
const asc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ordering',
|
||||||
|
orderBy: 'score',
|
||||||
|
order: 'asc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(asc).toHaveLength(3)
|
||||||
|
expect(asc.map((r: any) => r.id)).toEqual([ranked[0], ranked[1], missing])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ties on the orderBy field break by id ascending, in BOTH directions', async () => {
|
||||||
|
const tiedIds: string[] = []
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
tiedIds.push(
|
||||||
|
await brain.add({
|
||||||
|
data: `tied ${i}`,
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ties',
|
||||||
|
metadata: { score: 5 }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const expectedOrder = [...tiedIds].sort()
|
||||||
|
|
||||||
|
const asc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ties',
|
||||||
|
orderBy: 'score',
|
||||||
|
order: 'asc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
expect(asc.map((r: any) => r.id)).toEqual(expectedOrder)
|
||||||
|
|
||||||
|
const desc = await brain.find({
|
||||||
|
type: NounType.Person,
|
||||||
|
subtype: 'ns-law-ties',
|
||||||
|
orderBy: 'score',
|
||||||
|
order: 'desc',
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
// Same tie-break ordering regardless of the primary direction — the
|
||||||
|
// contract states one universal rule ("id ascending"), not "reverse of
|
||||||
|
// the primary order".
|
||||||
|
expect(desc.map((r: any) => r.id)).toEqual(expectedOrder)
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Rule 5 (options face) — unimplemented find() options refuse loudly
|
||||||
|
// instead of being accepted and silently ignored.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('find({ cursor }) refuses with UnsupportedFindOptionError', async () => {
|
||||||
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
||||||
|
await expectRefusal(brain.find({ cursor: 'anything', limit: 10 }), UnsupportedFindOptionError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find({ includeRelations }) refuses with UnsupportedFindOptionError', async () => {
|
||||||
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
||||||
|
await expectRefusal(brain.find({ includeRelations: true, limit: 10 }), UnsupportedFindOptionError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('find({ writeOnly }) refuses with UnsupportedFindOptionError', async () => {
|
||||||
|
await brain.add({ data: 'row', type: NounType.Person, metadata: { name: 'x' } })
|
||||||
|
await expectRefusal(brain.find({ writeOnly: true, limit: 10 }), UnsupportedFindOptionError)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue