The write side of the law, ruled 2026-08-03: data is either in main space where developers can use anything, or it is in system.*. - The reserved-name write door DIES: add/update/relate/updateRelation metadata bags accept EVERY name (confidence, type, id, data, level, content, ...) as ordinary user fields — indexed, filterable, sortable, aggregatable, identical to any other field. The remap/enforce/warn machinery, the reservedFieldPolicy config (now a typed init refusal), and the compile-time metadata key bans are all removed. The one write refusal left: keys spelled 'system.*' (namespace forgery), now enforced on all four write doors. - STORED RECORDS GO NESTED (v2): engine fields top-level, the user bag nested verbatim under 'metadata', sealed by a format stamp — by-name storage discrimination is unsound once colliders are admitted. Legacy flat records stay readable forever through the shape-aware splitters (sound for them: the old door refused colliders). Time travel rides the same split (generation store snapshots whole records). - Name-based index exclusions DIE: user frame indexes every name; the excludeFields/indexedFields knobs and their silent-[] holes are gone; bulk-payload protection is value-shape only, uniform across names. - Consumer-sweep findings fixed in the same wave: per-type counts read the frozen 'system.type' column (addToIndex sort, affinity tracking, cold-count rehydration, VFS type bitmaps — legacy 'noun' fallback for pre-rebuild reads); resolveHiddenIds addresses 'system.visibility' (bare 'visibility' was a silent no-op under the law — VFS/system entities leaked into default reads). - Fidelity fallout fixed in the owning layers: readEntityFieldAddress reads the bag first (colliders were absent-shadowed by its own guard) and never serves system addresses from the bag; blob history refs read the bag shape-aware; migration transforms now receive ONE normalized view (engine fields + nested bag) regardless of stored era, and stray flat-habit keys refuse with the fix in the message. - THE REOPEN-COLLIDER CONFORMANCE CASE (required before any RC counts as gates-green): all ten collider names + plumbing names written as user fields, verified verbatim + queryable across live reads, flush+reopen, a forced epoch rebuild, and asOf time travel; relation mirror; forgery refusals; legacy flat-record compat. 8/8 green. Gates: unit 1901/1901 (exit 0) · integration 758 (exit 0) · conformance 27/27 (exit 0) · consumer test sweep migrated (10 files).
172 lines
6 KiB
TypeScript
172 lines
6 KiB
TypeScript
/**
|
|
* @module tests/integration/aggregate-reserved-fields
|
|
* @description One field-resolution law across the whole aggregation + query
|
|
* surface (SELF-AGGREGATE-DELETE-DRIFT). Laws:
|
|
* (1) aggregates grouped by a RESERVED field (subtype) decrement on delete —
|
|
* the delete-side entity view carries every reserved field, so the
|
|
* decrement finds its group (counts must never drift from ground truth);
|
|
* (2) same for update: moving an entity between reserved-field groups
|
|
* decrements the old group and increments the new one (no double-count);
|
|
* (3) aggregation source.where on a reserved field (subtype) FILTERS instead
|
|
* of silently matching nothing;
|
|
* (4) removeMany refuses empty/invalid selectors loudly (bare array, empty
|
|
* object, ids: []) instead of resolving as a silent no-op;
|
|
* (5) find() accepts both where spellings: flattened (entry.title) and
|
|
* storage-shaped (metadata.entry.title) resolve to the same rows.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
import { Brainy } from '../../src/brainy.js'
|
|
import { NounType } from '../../src/types/graphTypes.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))
|
|
}
|
|
|
|
describe('aggregation + query field-resolution law', () => {
|
|
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()
|
|
})
|
|
|
|
it('reserved-field groupBy decrements on delete (the drift bug)', async () => {
|
|
brain.defineAggregate({
|
|
name: 'by_subtype',
|
|
// system.subtype — subtype is an add() param (an engine scalar), never
|
|
// a user metadata field; bare 'subtype' now addresses the user's own
|
|
// metadata bag under the sealed field-addressing law.
|
|
source: { type: NounType.Document },
|
|
groupBy: ['system.subtype'],
|
|
metrics: { count: { op: 'count' } }
|
|
})
|
|
|
|
const ids: string[] = []
|
|
for (let i = 0; i < 5; i++) {
|
|
ids.push(
|
|
await brain.add({
|
|
data: `doc-${i}`,
|
|
type: NounType.Document,
|
|
subtype: 'note',
|
|
metadata: { team: 'alpha' }
|
|
})
|
|
)
|
|
}
|
|
let groups = await brain.queryAggregate('by_subtype')
|
|
expect(groups).toHaveLength(1)
|
|
expect(groups[0].groupKey).toEqual({ 'system.subtype': 'note' })
|
|
expect(groups[0].metrics.count).toBe(5)
|
|
|
|
await brain.remove(ids[0])
|
|
await brain.flush()
|
|
|
|
groups = await brain.queryAggregate('by_subtype')
|
|
expect(groups[0].metrics.count).toBe(4)
|
|
const live = await brain.find({ type: NounType.Document, limit: 100 })
|
|
expect(groups[0].metrics.count).toBe(live.length)
|
|
})
|
|
|
|
it('reserved-field groupBy moves between groups on update (no double-count)', async () => {
|
|
brain.defineAggregate({
|
|
name: 'by_subtype',
|
|
source: { type: NounType.Document },
|
|
groupBy: ['system.subtype'],
|
|
metrics: { count: { op: 'count' } }
|
|
})
|
|
const id = await brain.add({
|
|
data: 'doc-move',
|
|
type: NounType.Document,
|
|
subtype: 'draft'
|
|
})
|
|
await brain.update({ id, subtype: 'published' })
|
|
|
|
const groups = await brain.queryAggregate('by_subtype')
|
|
const byKey = Object.fromEntries(
|
|
groups.map((g) => [String(g.groupKey['system.subtype']), g.metrics.count])
|
|
)
|
|
expect(byKey['published']).toBe(1)
|
|
// The old group must be gone or zero — never still counting the entity.
|
|
expect(byKey['draft'] ?? 0).toBe(0)
|
|
})
|
|
|
|
it('source.where on a reserved field filters instead of matching nothing', async () => {
|
|
brain.defineAggregate({
|
|
name: 'notes_only',
|
|
source: { type: NounType.Document, where: { 'system.subtype': 'note' } },
|
|
groupBy: ['team'],
|
|
metrics: { count: { op: 'count' } }
|
|
})
|
|
await brain.add({
|
|
data: 'n1',
|
|
type: NounType.Document,
|
|
subtype: 'note',
|
|
metadata: { team: 'alpha' }
|
|
})
|
|
await brain.add({
|
|
data: 'd1',
|
|
type: NounType.Document,
|
|
subtype: 'draft',
|
|
metadata: { team: 'alpha' }
|
|
})
|
|
|
|
const groups = await brain.queryAggregate('notes_only')
|
|
expect(groups).toHaveLength(1)
|
|
expect(groups[0].metrics.count).toBe(1) // the note, never the draft
|
|
})
|
|
|
|
it('removeMany refuses empty/invalid selectors loudly', async () => {
|
|
const id = await brain.add({ data: 'keep-me', type: NounType.Document })
|
|
|
|
// Bare array passed positionally — the classic silent no-op.
|
|
await expect(
|
|
brain.removeMany([id] as unknown as Parameters<typeof brain.removeMany>[0])
|
|
).rejects.toThrow(/bare array/)
|
|
// Empty selector object.
|
|
await expect(
|
|
brain.removeMany({} as Parameters<typeof brain.removeMany>[0])
|
|
).rejects.toThrow(/requires a selector/)
|
|
// Explicit empty id list.
|
|
await expect(brain.removeMany({ ids: [] })).rejects.toThrow(/ids: \[\]/)
|
|
|
|
// Nothing was deleted by any of the refused calls.
|
|
expect(await brain.get(id)).toBeTruthy()
|
|
})
|
|
|
|
it('find() accepts both flattened and metadata.-prefixed where spellings', async () => {
|
|
await brain.add({
|
|
data: 'nested-doc',
|
|
type: NounType.Document,
|
|
metadata: { entry: { title: 'T1' }, classifier: { contextHints: { vfsPath: '/n/a.md' } } }
|
|
})
|
|
await brain.flush()
|
|
|
|
const flat = await brain.find({
|
|
type: NounType.Document,
|
|
where: { 'entry.title': 'T1' },
|
|
limit: 10
|
|
})
|
|
const prefixed = await brain.find({
|
|
type: NounType.Document,
|
|
where: { 'metadata.entry.title': 'T1' },
|
|
limit: 10
|
|
})
|
|
const deepPrefixed = await brain.find({
|
|
type: NounType.Document,
|
|
where: { 'metadata.classifier.contextHints.vfsPath': '/n/a.md' },
|
|
limit: 10
|
|
})
|
|
expect(flat).toHaveLength(1)
|
|
expect(prefixed).toHaveLength(1)
|
|
expect(prefixed[0].id).toBe(flat[0].id)
|
|
expect(deepPrefixed).toHaveLength(1)
|
|
})
|
|
})
|