Compare commits

..

No commits in common. "d7444ae804853c57f1fc22328f56d3cca2e2ce3b" and "a2820e81afc629b76a3d842e9331902a082df70f" have entirely different histories.

6 changed files with 37 additions and 184 deletions

View file

@ -412,23 +412,18 @@ export class MigrationInProgressError extends BrainyError {
* embedding parked in the metadata bag would mint 384 postings for one row.
* The bound exists to keep that out of the index.
*
* 256 is hardcoded on purpose (the zero-config law: no knob). It sits far above
* 64 is hardcoded on purpose (the zero-config law: no knob). It sits far above
* every legitimate multi-value field the engine has seen tags, authors,
* categories, labels, keyword lists, participant lists and still below the
* narrowest embedding this engine will ever meet (384 dimensions, the smallest
* model it ships), so the two populations do not overlap and no caller has to
* tune it. A vector parked in metadata is refused; a long keyword list is not.
* categories, labels, participant lists and far below any real embedding
* width, so the two populations do not overlap and no caller has to tune it.
*
* It replaces a limit of 10 that was applied SILENTLY: a row whose `tags` array
* held eleven entries had that field skipped entirely and dropped out of every
* filtered search on it, with no error, no warning and no way to tell the
* difference from "no row matches". A rule this consequential is a law with a
* name and a refusal, not a `continue`.
*
* This is the ONE place the number lives. Every message, warning, doc line and
* pin derives it from here never a literal.
*/
export const MAX_INDEXED_ARRAY_LENGTH = 256
export const MAX_INDEXED_ARRAY_LENGTH = 64
/**
* A metadata field carries an array longer than {@link MAX_INDEXED_ARRAY_LENGTH}.

View file

@ -161,8 +161,7 @@ describe('Metadata Vector Exclusion Fix', () => {
// silence at a bound of 10 — the field simply vanished from the index and
// the row dropped out of every `where` on it, indistinguishably from "no
// row matches". The bound is now MAX_INDEXED_ARRAY_LENGTH and it REFUSES.
const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1
const largeArray = Array.from({ length: overTheBound }, (_, i) => `item${i}`)
const largeArray = Array.from({ length: 100 }, (_, i) => `item${i}`)
const err = await brainy
.add({
@ -177,7 +176,7 @@ describe('Metadata Vector Exclusion Fix', () => {
expect(err).toBeInstanceOf(MetadataArrayTooLargeError)
expect(err.field).toBe('items')
expect(err.length).toBe(overTheBound)
expect(err.length).toBe(100)
expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH)
// Nothing was indexed from the refused write — no 'items' field, and above

View file

@ -5,7 +5,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy'
import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError'
import {
createAddParams,
generateTestVector,
@ -269,23 +268,13 @@ describe('Brainy.get()', () => {
expect(entity!.id).toBe(id)
})
// THE INDEXABLE-ARRAY BOUND, from get()'s side. This case used to park a
// 1000-element array in the metadata bag and assert it came back. That
// shape is refused at the write door now — an array field mints one
// posting per element, so an unbounded array is an unbounded write — so
// the case pins BOTH halves of the law that replaced it: a large SCALAR
// payload still round-trips whole, and an array over the bound refuses by
// name. Every length derives from MAX_INDEXED_ARRAY_LENGTH so the pin
// follows the constant wherever it moves.
it('should get an entity with a large scalar metadata payload', async () => {
// Arrange — large in every dimension EXCEPT array length: a long string,
// many fields, deep nesting, and an array sitting exactly ON the bound.
it('should get entity with very large metadata', async () => {
// Arrange
const largeMetadata = {
atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`),
bigArray: new Array(1000).fill('item'),
bigObject: Object.fromEntries(
Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`])
),
longString: 'x'.repeat(10_000),
deepNesting: Array(10).fill(null).reduce(
(acc) => ({ nested: acc }),
{ value: 'deep' }
@ -301,43 +290,10 @@ describe('Brainy.get()', () => {
// Act
const entity = await brain.get(id)
// Assert — the payload comes back whole, first element to last
// Assert
expect(entity).not.toBeNull()
expect(entity!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH)
expect(entity!.metadata.atTheBound[0]).toBe('item0')
expect(entity!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1])
.toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`)
expect(entity!.metadata.bigArray).toHaveLength(1000)
expect(Object.keys(entity!.metadata.bigObject)).toHaveLength(100)
expect(entity!.metadata.longString).toHaveLength(10_000)
// ...including the deep nest, walked to the bottom.
let cursor: any = entity!.metadata.deepNesting
for (let depth = 0; depth < 10; depth++) cursor = cursor.nested
expect(cursor.value).toBe('deep')
})
it('should refuse a metadata array over the indexing bound, by name', async () => {
// Arrange
const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1
// Act
const err = await brain
.add(createAddParams({
data: 'Large metadata',
type: 'thing',
metadata: { bigArray: new Array(overTheBound).fill('item') }
}))
.catch((e: any) => e)
// Assert — the field, the length and the bound, on the error and in the
// message, so a handler can report or repair without parsing prose.
expect(err).toBeInstanceOf(MetadataArrayTooLargeError)
expect(err.field).toBe('bigArray')
expect(err.length).toBe(overTheBound)
expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH)
expect(err.message).toContain('bigArray')
expect(err.message).toContain(String(overTheBound))
expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH))
})
})

View file

@ -5,7 +5,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy'
import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError'
import {
createAddParams,
createTestConfig,
@ -249,21 +248,14 @@ describe('Brainy.relate()', () => {
expect(matches.length).toBe(1) // Only one relationship should exist
})
// THE INDEXABLE-ARRAY BOUND, from relate()'s side. This case used to pass a
// 100-element array through relate() and assert it came back — a length
// hardcoded either side of a bound it never named, so it read green or red
// purely by where the constant happened to sit. Both halves of the law are
// pinned here instead, and every length derives from
// MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant.
it('should handle a large scalar metadata payload on a relation', async () => {
// Arrange — large in every dimension EXCEPT array length: a long string,
// many fields, and an array sitting exactly ON the bound.
it('should handle very long metadata', async () => {
// Arrange
const largeMetadata = {
atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`),
bigArray: new Array(100).fill('item'),
bigObject: Object.fromEntries(
Array.from({ length: 50 }, (_, i) => [`key${i}`, `value${i}`])
),
longString: 'x'.repeat(10_000)
longString: 'x'.repeat(1000)
}
// Act
@ -274,45 +266,11 @@ describe('Brainy.relate()', () => {
metadata: largeMetadata
})
// Assert — the payload comes back whole, first element to last
// Assert
const relations = await brain.related({ from: entity1Id })
const relation = relations.find(r => r.to === entity2Id)
expect(relation).toBeDefined()
expect(relation!.metadata?.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH)
expect(relation!.metadata?.atTheBound[0]).toBe('item0')
expect(relation!.metadata?.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1])
.toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`)
expect(Object.keys(relation!.metadata?.bigObject)).toHaveLength(50)
expect(relation!.metadata?.longString).toHaveLength(10_000)
})
it('should refuse a relation metadata array over the indexing bound, by name', async () => {
// Arrange
const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1
// Act
const err = await brain
.relate({
from: entity1Id,
to: entity3Id,
type: 'relatedTo',
metadata: { bigArray: new Array(overTheBound).fill('item') }
})
.catch((e: any) => e)
// Assert — the field, the length and the bound, on the error and in the
// message, so a handler can report or repair without parsing prose.
expect(err).toBeInstanceOf(MetadataArrayTooLargeError)
expect(err.field).toBe('bigArray')
expect(err.length).toBe(overTheBound)
expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH)
expect(err.message).toContain('bigArray')
expect(err.message).toContain(String(overTheBound))
expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH))
// Refused means not written: no relation of this shape exists.
const relations = await brain.related({ from: entity1Id })
expect(relations.some(r => r.to === entity3Id && r.metadata?.bigArray)).toBe(false)
expect(relation!.metadata?.bigArray).toHaveLength(100)
})
it('should handle special characters in metadata', async () => {

View file

@ -5,7 +5,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy'
import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError'
import {
createAddParams,
createTestConfig,
@ -356,27 +355,18 @@ describe('Brainy.update()', () => {
expect(final!.metadata.counter).toBeLessThanOrEqual(10)
})
// THE INDEXABLE-ARRAY BOUND, from update()'s side. This case used to write
// a 1000-element array through update() and assert it came back. That
// shape is refused at the write door now — an array field mints one
// posting per element, so an unbounded array is an unbounded write — so
// the case pins BOTH halves of the law that replaced it. Every length
// derives from MAX_INDEXED_ARRAY_LENGTH so the pin follows the constant.
it('should handle a large scalar metadata update', async () => {
it('should handle very large metadata updates', async () => {
// Arrange
const id = await brain.add(createAddParams({
data: 'Large metadata test',
type: 'thing'
}))
// Large in every dimension EXCEPT array length: a long string, many
// fields, deep nesting, and an array sitting exactly ON the bound.
const largeMetadata = {
atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`),
bigArray: new Array(1000).fill('item'),
bigObject: Object.fromEntries(
Array.from({ length: 100 }, (_, i) => [`key${i}`, `value${i}`])
),
longString: 'x'.repeat(10_000),
deepNesting: Array(10).fill(null).reduce(
(acc) => ({ nested: acc }),
{ value: 'deep' }
@ -390,54 +380,11 @@ describe('Brainy.update()', () => {
merge: false
})
// Assert — the payload comes back whole, first element to last
// Assert
const updated = await brain.get(id)
expect(updated).not.toBeNull()
expect(updated!.metadata.atTheBound).toHaveLength(MAX_INDEXED_ARRAY_LENGTH)
expect(updated!.metadata.atTheBound[0]).toBe('item0')
expect(updated!.metadata.atTheBound[MAX_INDEXED_ARRAY_LENGTH - 1])
.toBe(`item${MAX_INDEXED_ARRAY_LENGTH - 1}`)
expect(updated!.metadata.bigArray).toHaveLength(1000)
expect(Object.keys(updated!.metadata.bigObject)).toHaveLength(100)
expect(updated!.metadata.longString).toHaveLength(10_000)
// ...including the deep nest, walked to the bottom.
let cursor: any = updated!.metadata.deepNesting
for (let depth = 0; depth < 10; depth++) cursor = cursor.nested
expect(cursor.value).toBe('deep')
})
it('should refuse an update whose metadata array is over the indexing bound, by name', async () => {
// Arrange
const id = await brain.add(createAddParams({
data: 'Large metadata test',
type: 'thing',
metadata: { keep: 'me' }
}))
const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1
// Act
const err = await brain
.update({
id,
metadata: { bigArray: new Array(overTheBound).fill('item') },
merge: false
})
.catch((e: any) => e)
// Assert — the field, the length and the bound, on the error and in the
// message, so a handler can report or repair without parsing prose.
expect(err).toBeInstanceOf(MetadataArrayTooLargeError)
expect(err.field).toBe('bigArray')
expect(err.length).toBe(overTheBound)
expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH)
expect(err.message).toContain('bigArray')
expect(err.message).toContain(String(overTheBound))
expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH))
// Refused means unchanged: the row still carries what it had before.
const unchanged = await brain.get(id)
expect(unchanged!.metadata.keep).toBe('me')
expect(unchanged!.metadata.bigArray).toBeUndefined()
})
it('should preserve entity ID during update', async () => {

View file

@ -15,10 +15,9 @@
* and the caller had no way to tell that from "no row matches". Eleven tags is
* not an exotic shape; the eleventh tag made the row invisible.
*
* THE LAW. Arrays of scalars index up to {@link MAX_INDEXED_ARRAY_LENGTH},
* THE LAW. Arrays of scalars index up to {@link MAX_INDEXED_ARRAY_LENGTH} = 64,
* hardcoded (the zero-config law: no knob), which clears every legitimate
* multi-value field tags, authors, keyword lists and stays below the
* narrowest embedding this engine meets (384 dimensions). Above it the WRITE
* multi-value field and stays far below any embedding width. Above it the WRITE
* IS REFUSED by name `MetadataArrayTooLargeError`, carrying the field, the
* length and the bound at `add`, `update`, `relate` and `updateRelation`
* alike. Nothing is skipped in silence.
@ -66,7 +65,7 @@ describe('the indexable-array bound', () => {
}
})
it('indexes right up to the bound — every element of it', async () => {
it('indexes right up to the bound — all 64 elements', async () => {
await brain.add({
id: 'at-bound',
data: 'a row at the bound',
@ -75,9 +74,8 @@ describe('the indexable-array bound', () => {
vector: []
})
// The first, the last, and one in the middle — all derived from the
// bound, so the case follows the constant wherever it moves.
for (const tag of ['t0', `t${MAX_INDEXED_ARRAY_LENGTH - 1}`, `t${Math.floor(MAX_INDEXED_ARRAY_LENGTH / 2)}`]) {
// The first, the last, and one in the middle.
for (const tag of ['t0', `t${MAX_INDEXED_ARRAY_LENGTH - 1}`, 't31']) {
const hits = await brain.find({ where: { tags: tag }, limit: 10 } as any)
expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('at-bound'))
}