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).
297 lines
No EOL
9.8 KiB
TypeScript
297 lines
No EOL
9.8 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import {
|
|
validateFindParams,
|
|
validateAddParams,
|
|
validateUpdateParams,
|
|
validateRelateParams,
|
|
getValidationConfig,
|
|
recordQueryPerformance
|
|
} from '../../../src/utils/paramValidation.js'
|
|
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
|
import { FindParams, AddParams, UpdateParams, RelateParams } from '../../../src/types/brainy.types.js'
|
|
|
|
describe('Zero-Config Parameter Validation', () => {
|
|
|
|
describe('validateFindParams', () => {
|
|
|
|
it('should accept valid parameters', () => {
|
|
expect(() => validateFindParams({
|
|
query: 'test query',
|
|
limit: 10,
|
|
offset: 0
|
|
})).not.toThrow()
|
|
|
|
expect(() => validateFindParams({
|
|
type: NounType.Document,
|
|
where: { status: 'active' }
|
|
})).not.toThrow()
|
|
})
|
|
|
|
it('should reject negative limit', () => {
|
|
expect(() => validateFindParams({
|
|
limit: -1
|
|
})).toThrow('limit must be non-negative')
|
|
})
|
|
|
|
it('should reject negative offset', () => {
|
|
expect(() => validateFindParams({
|
|
offset: -1
|
|
})).toThrow('offset must be non-negative')
|
|
})
|
|
|
|
it('should reject threshold outside 0-1 range', () => {
|
|
expect(() => validateFindParams({
|
|
near: { id: 'test', threshold: -0.1 }
|
|
})).toThrow('threshold must be between 0 and 1')
|
|
|
|
expect(() => validateFindParams({
|
|
near: { id: 'test', threshold: 1.1 }
|
|
})).toThrow('threshold must be between 0 and 1')
|
|
})
|
|
|
|
it('should reject both query and vector', () => {
|
|
expect(() => validateFindParams({
|
|
query: 'test',
|
|
vector: new Array(384).fill(0)
|
|
})).toThrow('cannot specify both query and vector')
|
|
})
|
|
|
|
it('should refuse cursor outright — even paired with offset — as an unimplemented option', () => {
|
|
// cursor is now a typed, unconditional refusal (UnsupportedFindOptionError):
|
|
// it used to be accepted-and-ignored, only conflicting when offset was also
|
|
// given. Accepted-and-ignored died as a class — cursor refuses on its own,
|
|
// so pairing it with offset refuses too, but with the SAME message.
|
|
expect(() => validateFindParams({
|
|
cursor: 'abc123',
|
|
offset: 10
|
|
})).toThrow("find() option 'cursor' is not implemented")
|
|
})
|
|
|
|
it('should validate vector dimensions', () => {
|
|
expect(() => validateFindParams({
|
|
vector: new Array(100).fill(0) // Wrong dimensions
|
|
})).toThrow('vector must have exactly 384 dimensions')
|
|
|
|
expect(() => validateFindParams({
|
|
vector: new Array(384).fill(0) // Correct dimensions
|
|
})).not.toThrow()
|
|
})
|
|
|
|
it('should validate NounType enum', () => {
|
|
expect(() => validateFindParams({
|
|
type: 'InvalidType' as any
|
|
})).toThrow('invalid NounType: InvalidType')
|
|
|
|
expect(() => validateFindParams({
|
|
type: NounType.Document
|
|
})).not.toThrow()
|
|
})
|
|
|
|
it('should validate array of NounTypes', () => {
|
|
expect(() => validateFindParams({
|
|
type: [NounType.Document, NounType.Person]
|
|
})).not.toThrow()
|
|
|
|
expect(() => validateFindParams({
|
|
type: [NounType.Document, 'InvalidType' as any]
|
|
})).toThrow('invalid NounType: InvalidType')
|
|
})
|
|
|
|
it('should auto-limit based on system memory (two-tier enforcement)', () => {
|
|
const config = getValidationConfig()
|
|
|
|
// 7.30.2+ design: the cap fires in two tiers.
|
|
// - Below cap (`limit <= maxLimit`): silent pass, no signal.
|
|
// - Soft tier (`maxLimit < limit <= 2 * maxLimit`): one-time warning
|
|
// per call site, query proceeds. No throw.
|
|
// - Hard tier (`limit > 2 * maxLimit`): real OOM danger zone, throw.
|
|
|
|
// Below cap → pass
|
|
expect(() => validateFindParams({ limit: config.maxLimit })).not.toThrow()
|
|
|
|
// Soft tier → no throw (just a warn we don't assert here — proper coverage
|
|
// lives in the find-limits integration suite which can intercept the log).
|
|
expect(() => validateFindParams({ limit: config.maxLimit + 1 })).not.toThrow()
|
|
expect(() => validateFindParams({ limit: config.maxLimit * 2 })).not.toThrow()
|
|
|
|
// Hard tier → throw with the new message format
|
|
expect(() => validateFindParams({
|
|
limit: config.maxLimit * 2 + 1
|
|
})).toThrow(/exceeds the auto-configured query limit/)
|
|
})
|
|
|
|
it('should auto-limit query length', () => {
|
|
const config = getValidationConfig()
|
|
const longQuery = 'a'.repeat(config.maxQueryLength + 1)
|
|
|
|
expect(() => validateFindParams({
|
|
query: longQuery
|
|
})).toThrow(`query exceeds auto-configured maximum length of ${config.maxQueryLength}`)
|
|
})
|
|
})
|
|
|
|
describe('validateAddParams', () => {
|
|
|
|
it('should accept valid add parameters', () => {
|
|
expect(() => validateAddParams({
|
|
data: 'test content',
|
|
type: NounType.Document
|
|
})).not.toThrow()
|
|
|
|
expect(() => validateAddParams({
|
|
vector: new Array(384).fill(0),
|
|
type: NounType.Person
|
|
})).not.toThrow()
|
|
})
|
|
|
|
it('should require either data or vector', () => {
|
|
expect(() => validateAddParams({
|
|
type: NounType.Document
|
|
} as AddParams)).toThrow('Invalid add() parameters: Missing required field \'data\'')
|
|
})
|
|
|
|
it('should validate NounType', () => {
|
|
expect(() => validateAddParams({
|
|
data: 'test',
|
|
type: 'InvalidType' as any
|
|
})).toThrow('Invalid NounType: \'InvalidType\'')
|
|
})
|
|
|
|
it('should validate vector dimensions', () => {
|
|
expect(() => validateAddParams({
|
|
vector: new Array(100).fill(0),
|
|
type: NounType.Document
|
|
})).toThrow('vector must have exactly 384 dimensions')
|
|
})
|
|
})
|
|
|
|
describe('validateUpdateParams', () => {
|
|
|
|
it('should accept valid update parameters', () => {
|
|
expect(() => validateUpdateParams({
|
|
id: 'test-id',
|
|
data: 'new content'
|
|
})).not.toThrow()
|
|
|
|
expect(() => validateUpdateParams({
|
|
id: 'test-id',
|
|
metadata: { status: 'updated' }
|
|
})).not.toThrow()
|
|
})
|
|
|
|
it('should require an ID', () => {
|
|
expect(() => validateUpdateParams({
|
|
data: 'new content'
|
|
} as UpdateParams)).toThrow('id is required for update')
|
|
})
|
|
|
|
it('should require at least one field to update', () => {
|
|
expect(() => validateUpdateParams({
|
|
id: 'test-id'
|
|
})).toThrow('must specify at least one field to update')
|
|
})
|
|
|
|
it('should validate NounType if changing', () => {
|
|
expect(() => validateUpdateParams({
|
|
id: 'test-id',
|
|
type: 'InvalidType' as any
|
|
})).toThrow('invalid NounType: InvalidType')
|
|
|
|
expect(() => validateUpdateParams({
|
|
id: 'test-id',
|
|
type: NounType.Event
|
|
})).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('validateRelateParams', () => {
|
|
|
|
it('should accept valid relate parameters', () => {
|
|
expect(() => validateRelateParams({
|
|
from: 'entity1',
|
|
to: 'entity2',
|
|
type: VerbType.RelatedTo
|
|
})).not.toThrow()
|
|
|
|
expect(() => validateRelateParams({
|
|
from: 'entity1',
|
|
to: 'entity2',
|
|
type: VerbType.Creates,
|
|
weight: 0.8
|
|
})).not.toThrow()
|
|
})
|
|
|
|
it('should require from and to', () => {
|
|
expect(() => validateRelateParams({
|
|
to: 'entity2',
|
|
type: VerbType.RelatedTo
|
|
} as RelateParams)).toThrow('from entity ID is required')
|
|
|
|
expect(() => validateRelateParams({
|
|
from: 'entity1',
|
|
type: VerbType.RelatedTo
|
|
} as RelateParams)).toThrow('to entity ID is required')
|
|
})
|
|
|
|
// Self-referential relationships are now allowed (valid in graph systems)
|
|
// Previous test "should reject self-referential relationships" removed
|
|
|
|
it('should validate VerbType', () => {
|
|
expect(() => validateRelateParams({
|
|
from: 'entity1',
|
|
to: 'entity2',
|
|
type: 'InvalidVerb' as any
|
|
})).toThrow('invalid VerbType: InvalidVerb')
|
|
})
|
|
|
|
it('should validate weight range', () => {
|
|
expect(() => validateRelateParams({
|
|
from: 'entity1',
|
|
to: 'entity2',
|
|
type: VerbType.RelatedTo,
|
|
weight: -0.1
|
|
})).toThrow('weight must be between 0 and 1')
|
|
|
|
expect(() => validateRelateParams({
|
|
from: 'entity1',
|
|
to: 'entity2',
|
|
type: VerbType.RelatedTo,
|
|
weight: 1.1
|
|
})).toThrow('weight must be between 0 and 1')
|
|
})
|
|
})
|
|
|
|
describe('Auto-configuration', () => {
|
|
|
|
it('should provide configuration based on system resources', () => {
|
|
const config = getValidationConfig()
|
|
|
|
expect(config.maxLimit).toBeGreaterThan(0)
|
|
expect(config.maxLimit).toBeLessThanOrEqual(100000)
|
|
expect(config.maxQueryLength).toBeGreaterThan(0)
|
|
expect(config.maxVectorDimensions).toBe(384)
|
|
expect(config.systemMemory).toBeGreaterThan(0)
|
|
expect(config.availableMemory).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('never mutates the cap from query timing (telemetry only)', () => {
|
|
const initialLimit = getValidationConfig().maxLimit
|
|
|
|
// Fast queries with large results: no silent growth.
|
|
for (let i = 0; i < 10; i++) {
|
|
recordQueryPerformance(50, initialLimit * 0.9)
|
|
}
|
|
expect(getValidationConfig().maxLimit).toBe(initialLimit)
|
|
|
|
// A burst of catastrophically slow queries must not strangle the cap.
|
|
// The removed "learning" ratchet shrank it 20% per recorded query down
|
|
// to a floor of 1000 — below the documented MIN_AUTO_QUERY_LIMIT — and
|
|
// the error message blamed "available free memory" (a production
|
|
// incident: every find({ limit: 5000 }) failed on an idle 23GB-free box).
|
|
for (let i = 0; i < 50; i++) {
|
|
recordQueryPerformance(90_000, 100)
|
|
}
|
|
expect(getValidationConfig().maxLimit).toBe(initialLimit)
|
|
})
|
|
})
|
|
}) |