diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index 4301d3f7..2fbdbe8d 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -412,18 +412,23 @@ 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. * - * 64 is hardcoded on purpose (the zero-config law: no knob). It sits far above + * 256 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, participant lists — and far below any real embedding - * width, so the two populations do not overlap and no caller has to tune it. + * 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. * * 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 = 64 +export const MAX_INDEXED_ARRAY_LENGTH = 256 /** * A metadata field carries an array longer than {@link MAX_INDEXED_ARRAY_LENGTH}. diff --git a/tests/integration/idle-costs-nothing.test.ts b/tests/integration/idle-costs-nothing.test.ts index b5c386cf..2374627e 100644 --- a/tests/integration/idle-costs-nothing.test.ts +++ b/tests/integration/idle-costs-nothing.test.ts @@ -88,11 +88,36 @@ describe('an idle brain costs nothing', () => { } // (a) + (b): nothing ran, nothing was said. - expect(logged.filter((l) => /All indexes flushed to disk/.test(l))).toEqual([]) - expect(logged.filter((l) => /Flushing Brainy indexes/.test(l))).toEqual([]) + // + // THE SPIES COME FIRST, AND THEY ARE THE ATTRIBUTABLE HALF. They are bound + // to THIS brain's providers, so they answer "did this brain flush?" and + // nothing else. The console filters below cannot: the gate config runs the + // whole suite in ONE process (`pool: 'forks'`, `singleFork: true`), so + // `console.log` carries the narration of every brain alive in that + // process — including one a previous file opened and never closed, whose + // unref'd cadence timer is still doing honest work. A neighbour narrating + // is a REAL finding about suite hygiene, but it is not this brain failing + // its own law, and the two must not be reported as the same thing. + // + // So: spies first (whose failure means the engine broke the law), console + // second (whose failure means SOMETHING in the process narrated), and the + // console assertion carries the captured lines in its message. vitest's + // stdout blocks are prefixed `stdout | > `, so those lines + // plus the surrounding gate log name the brain that printed them. expect(countsSpy).not.toHaveBeenCalled() expect(metadataSpy).not.toHaveBeenCalled() expect(graphSpy).not.toHaveBeenCalled() + + const flushChatter = logged.filter( + (l) => /All indexes flushed to disk/.test(l) || /Flushing Brainy indexes/.test(l) + ) + expect( + flushChatter, + `a flush narrated during the ${IDLE_WATCH_MS}ms idle window. This brain's own ` + + `providers were NOT called (asserted above), so the lines below were printed by ` + + `another brain alive in this process — find it by the 'stdout | > ' ` + + `prefix in the run log:\n${flushChatter.join('\n')}` + ).toEqual([]) }, 180_000) it('an explicit flush over a clean brain calls no provider and prints nothing', async () => { diff --git a/tests/integration/metadata-vector-exclusion.test.ts b/tests/integration/metadata-vector-exclusion.test.ts index 0ca25388..1943b215 100644 --- a/tests/integration/metadata-vector-exclusion.test.ts +++ b/tests/integration/metadata-vector-exclusion.test.ts @@ -161,7 +161,8 @@ 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 largeArray = Array.from({ length: 100 }, (_, i) => `item${i}`) + const overTheBound = MAX_INDEXED_ARRAY_LENGTH + 1 + const largeArray = Array.from({ length: overTheBound }, (_, i) => `item${i}`) const err = await brainy .add({ @@ -176,7 +177,7 @@ describe('Metadata Vector Exclusion Fix', () => { expect(err).toBeInstanceOf(MetadataArrayTooLargeError) expect(err.field).toBe('items') - expect(err.length).toBe(100) + expect(err.length).toBe(overTheBound) expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) // Nothing was indexed from the refused write — no 'items' field, and above diff --git a/tests/unit/brainy/get.test.ts b/tests/unit/brainy/get.test.ts index b39bf2e1..97a19125 100644 --- a/tests/unit/brainy/get.test.ts +++ b/tests/unit/brainy/get.test.ts @@ -5,7 +5,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' +import { createAddParams, generateTestVector, createTestConfig, @@ -268,32 +269,75 @@ describe('Brainy.get()', () => { expect(entity!.id).toBe(id) }) - it('should get entity with very large metadata', async () => { - // Arrange + // 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. const largeMetadata = { - bigArray: new Array(1000).fill('item'), + atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), 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' } ) } - + const id = await brain.add(createAddParams({ data: 'Large metadata', type: 'thing', metadata: largeMetadata })) - + // Act const entity = await brain.get(id) - - // Assert + + // Assert — the payload comes back whole, first element to last expect(entity).not.toBeNull() - expect(entity!.metadata.bigArray).toHaveLength(1000) + 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(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)) }) }) diff --git a/tests/unit/brainy/relate.test.ts b/tests/unit/brainy/relate.test.ts index eb1a036e..bea35ba3 100644 --- a/tests/unit/brainy/relate.test.ts +++ b/tests/unit/brainy/relate.test.ts @@ -5,7 +5,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -248,16 +249,23 @@ describe('Brainy.relate()', () => { expect(matches.length).toBe(1) // Only one relationship should exist }) - it('should handle very long metadata', async () => { - // Arrange + // 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. const largeMetadata = { - bigArray: new Array(100).fill('item'), + atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), bigObject: Object.fromEntries( Array.from({ length: 50 }, (_, i) => [`key${i}`, `value${i}`]) ), - longString: 'x'.repeat(1000) + longString: 'x'.repeat(10_000) } - + // Act await brain.relate({ from: entity1Id, @@ -265,12 +273,46 @@ describe('Brainy.relate()', () => { type: 'relatedTo', metadata: largeMetadata }) - - // Assert + + // Assert — the payload comes back whole, first element to last const relations = await brain.related({ from: entity1Id }) const relation = relations.find(r => r.to === entity2Id) expect(relation).toBeDefined() - expect(relation!.metadata?.bigArray).toHaveLength(100) + 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) }) it('should handle special characters in metadata', async () => { diff --git a/tests/unit/brainy/update.test.ts b/tests/unit/brainy/update.test.ts index 19fdad19..ec5f3fff 100644 --- a/tests/unit/brainy/update.test.ts +++ b/tests/unit/brainy/update.test.ts @@ -5,7 +5,8 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../../src/brainy' -import { +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' +import { createAddParams, createTestConfig, } from '../../helpers/test-factory' @@ -355,36 +356,88 @@ describe('Brainy.update()', () => { expect(final!.metadata.counter).toBeLessThanOrEqual(10) }) - it('should handle very large metadata updates', async () => { + // 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 () => { // 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 = { - bigArray: new Array(1000).fill('item'), + atTheBound: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`), 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' } ) } - + // Act await brain.update({ id, metadata: largeMetadata, merge: false }) - - // Assert + + // Assert — the payload comes back whole, first element to last const updated = await brain.get(id) expect(updated).not.toBeNull() - expect(updated!.metadata.bigArray).toHaveLength(1000) + 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(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 () => { diff --git a/tests/unit/utils/metadataIndex-array-bound.test.ts b/tests/unit/utils/metadataIndex-array-bound.test.ts index cbbf6b63..a96ae1d6 100644 --- a/tests/unit/utils/metadataIndex-array-bound.test.ts +++ b/tests/unit/utils/metadataIndex-array-bound.test.ts @@ -15,9 +15,10 @@ * 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} = 64, + * THE LAW. Arrays of scalars index up to {@link MAX_INDEXED_ARRAY_LENGTH}, * hardcoded (the zero-config law: no knob), which clears every legitimate - * multi-value field and stays far below any embedding width. Above it the WRITE + * multi-value field — tags, authors, keyword lists — and stays below the + * narrowest embedding this engine meets (384 dimensions). 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. @@ -65,7 +66,7 @@ describe('the indexable-array bound', () => { } }) - it('indexes right up to the bound — all 64 elements', async () => { + it('indexes right up to the bound — every element of it', async () => { await brain.add({ id: 'at-bound', data: 'a row at the bound', @@ -74,8 +75,9 @@ describe('the indexable-array bound', () => { vector: [] }) - // The first, the last, and one in the middle. - for (const tag of ['t0', `t${MAX_INDEXED_ARRAY_LENGTH - 1}`, 't31']) { + // 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)}`]) { const hits = await brain.find({ where: { tags: tag }, limit: 10 } as any) expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('at-bound')) }