From 0d5ab6077da73a27946b54efaac0e5baae2e95c6 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Wed, 2 Sep 2026 13:43:14 -0700 Subject: [PATCH] fix(metadata): the indexable-array bound is a named law with a refusal, not a silent skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An array-valued metadata field indexes one posting per element, so the index has always carried a ceiling. It was 10, and it was applied by a bare `continue` deep inside field extraction: if (Array.isArray(value) && value.length > 10) continue A row whose `tags` array held ELEVEN entries therefore had that field skipped entirely — no posting, no error, no warning. The row then failed to match every filtered search on `tags`, including a query for a tag it demonstrably held, 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. Measured on the pin here: the where-clause returns [] on the base for all eleven values. The ceiling is not the defect. The silence was. THE LAW. MAX_INDEXED_ARRAY_LENGTH = 64, hardcoded (the zero-config law: no knob), sitting far above every legitimate multi-value field — tags, authors, categories, labels, participants — and far below any real embedding width, so the two populations do not overlap and nobody has to tune it. Arrays of scalars index in full up to the bound. Above it the WRITE IS REFUSED by name: MetadataArrayTooLargeError carries the field (its full dotted address), the length and the bound, and names the three cures. It fires at all four write doors — add, update, relate, updateRelation — beside the existing forged-system- key rejection, and walks nested bags because a nested field indexes under its dotted address exactly like a top-level one. THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an older engine under the old rule and read back by a rebuild, a catch-up fold or a remove. extractIndexableFields serves all three, so refusing there would make an existing store un-rebuildable — the row is admitted and the skipped field is NARRATED with the field, the length and the bound. Never silent, either way. tests/integration/metadata-vector-exclusion.test.ts carried the old law as a green assertion ("should skip indexing large arrays (>10 elements)"). It is rewritten to the new one, plus a case proving a 64-element array indexes in full and its eleventh element is searchable. The original bug that suite exists for — per-dimension numeric field explosion — is still asserted on both paths. --- src/errors/brainyError.ts | 65 +++++ src/index.ts | 2 +- src/utils/metadataIndex.ts | 45 +++- src/utils/paramValidation.ts | 49 ++++ .../metadata-vector-exclusion.test.ts | 58 +++-- .../utils/metadataIndex-array-bound.test.ts | 242 ++++++++++++++++++ 6 files changed, 436 insertions(+), 25 deletions(-) create mode 100644 tests/unit/utils/metadataIndex-array-bound.test.ts diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index a58236e3..4301d3f7 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -405,3 +405,68 @@ export class MigrationInProgressError extends BrainyError { } } } + +/** + * THE INDEXABLE-ARRAY BOUND. An array-valued metadata field indexes one posting + * per element, so an unbounded array is an unbounded write — a 384-float + * 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 + * 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. + * + * 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`. + */ +export const MAX_INDEXED_ARRAY_LENGTH = 64 + +/** + * A metadata field carries an array longer than {@link MAX_INDEXED_ARRAY_LENGTH}. + * + * Thrown at the WRITE door (`add` / `update` / `relate` / `updateRelation`), so + * the caller learns at the moment of writing that the field will not be + * searchable — rather than discovering it later as rows that quietly fail to + * match. Carries the field, its length and the bound so a handler can report + * or repair without parsing the message. + * + * The cure is one of: store the long array outside the indexed bag (`data` + * carries arbitrary content and is not indexed element-wise); pass an embedding + * as the first-class `vector` parameter, which is where a vector belongs; or + * shorten the field to the values that are actually queried. + */ +export class MetadataArrayTooLargeError extends BrainyError { + /** The metadata field whose array is too long (its full dotted address). */ + public readonly field: string + /** How many elements that array holds. */ + public readonly length: number + /** The bound it exceeded — {@link MAX_INDEXED_ARRAY_LENGTH}. */ + public readonly limit: number + + constructor(site: string, field: string, length: number, limit: number) { + super( + `${site}: metadata field '${field}' holds ${length} array elements, ` + + `over the ${limit}-element indexing bound. An array field indexes one ` + + `posting per element, so an unbounded array is an unbounded write. ` + + `This write is refused rather than indexed partially or skipped silently ` + + `— a skipped field drops the row out of every filtered search on '${field}' ` + + `with no way to tell that from "nothing matched". ` + + `Cures: put the long array in 'data' (stored, not indexed element-wise); ` + + `pass an embedding as the first-class 'vector' parameter; or keep only ` + + `the values you actually query in '${field}'.`, + 'VALIDATION', + false + ) + this.name = 'MetadataArrayTooLargeError' + this.field = field + this.length = length + this.limit = limit + if (Error.captureStackTrace) { + Error.captureStackTrace(this, MetadataArrayTooLargeError) + } + } +} diff --git a/src/index.ts b/src/index.ts index e946f15c..673e1e6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -203,7 +203,7 @@ export { EntityNotFoundError, RelationNotFoundError } from './errors/notFound.js // Base error + typed migration-lock error — thrown by any data-plane call while a // brain runs its one-time 7.x→8.0 upgrade; catch to answer HTTP 503 + Retry-After. -export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError } from './errors/brainyError.js' +export { BrainyError, MigrationInProgressError, GraphIndexNotReadyError, MetadataIndexNotReadyError, VectorIndexNotReadyError, ProtectedArtifactError, DerivedArtifactMissingError, MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from './errors/brainyError.js' export type { BrainyErrorType } from './errors/brainyError.js' // ============= 8.0 Db API — generational MVCC ============= diff --git a/src/utils/metadataIndex.ts b/src/utils/metadataIndex.ts index 83d37379..1a882945 100644 --- a/src/utils/metadataIndex.ts +++ b/src/utils/metadataIndex.ts @@ -40,7 +40,7 @@ import { import { EntityIdMapper } from './entityIdMapper.js' import { RoaringBitmap32, roaringLibraryInitialize } from './roaring/index.js' import { FieldTypeInference, FieldType } from './fieldTypeInference.js' -import { BrainyError } from '../errors/brainyError.js' +import { BrainyError, MAX_INDEXED_ARRAY_LENGTH } from '../errors/brainyError.js' /** * Fields whose values are stored in the sparse index as BUCKETED values @@ -289,8 +289,10 @@ export class MetadataIndexManager implements MetadataIndexProvider { // No name-based exclude/allow lists — the field-addressing law: every // user field indexes, whatever its name ('content', 'data', 'id', // 'vector', … included). Bulk payloads are kept out by uniform value- - // SHAPE rules in extractIndexableFields (arrays >10 never become - // posting scalars; >100-char values index hashed), never by name. + // SHAPE rules in extractIndexableFields (arrays longer than + // MAX_INDEXED_ARRAY_LENGTH never become posting scalars, and the write + // door refuses them by name; >100-char values index hashed), never by + // field name. } // Initialize metadata cache with similar config to search cache @@ -1387,9 +1389,10 @@ export class MetadataIndexManager implements MetadataIndexProvider { * 'content', 'vector' in a bag are ordinary user fields) * - Record-frame plumbing (vector, connections, level, data, _rev, id) * never indexes — that is namespace routing, not a name carve-out - * - Value-SHAPE rules apply uniformly to all names: arrays >10 never - * become posting scalars; purely numeric key names (array indices) - * skip; >100-char values index hashed (normalizeValue) + * - Value-SHAPE rules apply uniformly to all names: arrays longer than + * MAX_INDEXED_ARRAY_LENGTH never become posting scalars (and say so — + * the write door refuses them outright); purely numeric key names + * (array indices) skip; >100-char values index hashed (normalizeValue) */ private extractIndexableFields(data: any): Array<{ field: string, value: any }> { const fields: Array<{ field: string, value: any }> = [] @@ -1451,13 +1454,37 @@ export class MetadataIndexManager implements MetadataIndexProvider { // This catches vectors stored as objects: {0: 0.1, 1: 0.2, ...} if (/^\d+$/.test(key)) continue - // Skip large arrays (> 10 elements) - likely vectors or bulk data - if (Array.isArray(value) && value.length > 10) continue + // THE INDEXABLE-ARRAY BOUND ({@link MAX_INDEXED_ARRAY_LENGTH}). An + // array field mints one posting per element, so the index has always + // carried a ceiling — it was 10, and it was applied by this bare + // `continue`: an eleven-element `tags` array had its whole field + // skipped and the row dropped out of every filtered search on it, with + // no error, no warning, and nothing to distinguish that from "no row + // matches". The ceiling is not the defect; the silence was. + // + // The write door refuses this shape by name now + // (`MetadataArrayTooLargeError`, thrown from paramValidation's + // `rejectOversizeIndexArrays`), so a live add/update never reaches + // here over the bound. Reaching it means the row is ALREADY on disk — + // written by an older engine under the old rule — and this is a + // rebuild, a catch-up fold or a remove reading it back. Refusing there + // would make an existing store un-rebuildable, so the row is admitted + // and the skipped field is NARRATED instead. Never silent, either way. + if (Array.isArray(value) && value.length > MAX_INDEXED_ARRAY_LENGTH) { + prodLog.warn( + `[brainy] metadata field '${fullKey}' holds ${value.length} array elements, ` + + `over the ${MAX_INDEXED_ARRAY_LENGTH}-element indexing bound — the field is ` + + `NOT indexed for this row, so it will not match a where-clause on '${fullKey}'. ` + + `This row predates the bound (the write door refuses this shape now). ` + + `Move the long array into 'data', or pass an embedding as the 'vector' parameter.` + ) + continue + } if (value && typeof value === 'object' && !Array.isArray(value)) { // Recurse into nested objects (but not arrays), keeping the frame extract(value, fullKey, frame) - } else if (Array.isArray(value) && value.length <= 10) { + } else if (Array.isArray(value)) { // Small arrays: index as multi-value field (all with same field name) // Example: tags: ["javascript", "node"] → field="tags", value="javascript" + field="tags", value="node" for (const item of value) { diff --git a/src/utils/paramValidation.ts b/src/utils/paramValidation.ts index 00790a4a..f1addb5b 100644 --- a/src/utils/paramValidation.ts +++ b/src/utils/paramValidation.ts @@ -18,6 +18,7 @@ import { findCallerLocation } from './callerLocation.js' import * as os from 'node:os' import * as fs from 'node:fs' import { parseFieldAddress, UnsupportedFindOptionError } from '../db/fieldAddressing.js' +import { MAX_INDEXED_ARRAY_LENGTH, MetadataArrayTooLargeError } from '../errors/brainyError.js' const getSystemMemory = (): number => { if (os) { @@ -538,8 +539,53 @@ function rejectForgedSystemKeys(metadata: Record | undefined, s } } +/** + * THE INDEXABLE-ARRAY BOUND, enforced at the write door. + * + * An array-valued metadata field indexes one posting per element, so the index + * has always carried a ceiling. It used to be 10, and it was applied by a bare + * `continue` deep inside field extraction: a row whose `tags` array held eleven + * entries had that field skipped entirely and dropped out of every filtered + * search on it — no error, no warning, and no way for the caller to tell the + * difference from "no row matches". Silence is the defect; the ceiling is not. + * + * The bound is now {@link MAX_INDEXED_ARRAY_LENGTH}, high enough that every + * legitimate multi-value field clears it, and it REFUSES here instead of + * dropping data downstream. Refusing at the write door is what makes it + * actionable: the caller learns at the moment of writing, with the field, the + * length and the bound in hand. + * + * Scope is the caller's own metadata bag — the values that become postings. + * Nested bags are walked, because a nested field indexes under its dotted + * address exactly like a top-level one. Arrays of OBJECTS are not walked: the + * index only ever makes postings from an array's scalar elements. + * + * @param metadata - The caller's metadata bag (undefined is fine). + * @param site - The write door's name, for the message ('add()', 'update()', …). + * @throws {MetadataArrayTooLargeError} Naming the field, its length and the bound. + */ +function rejectOversizeIndexArrays(metadata: Record | undefined, site: string): void { + if (!metadata) return + + const walk = (bag: Record, prefix: string): void => { + for (const [key, value] of Object.entries(bag)) { + const address = prefix ? `${prefix}.${key}` : key + if (Array.isArray(value)) { + if (value.length > MAX_INDEXED_ARRAY_LENGTH) { + throw new MetadataArrayTooLargeError(site, address, value.length, MAX_INDEXED_ARRAY_LENGTH) + } + } else if (value && typeof value === 'object') { + walk(value as Record, address) + } + } + } + + walk(metadata, '') +} + export function validateAddParams(params: AddParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'add()') + rejectOversizeIndexArrays(params.metadata as Record | undefined, 'add()') // 'data' is ABSENT only when null/undefined — an empty string ('') is real // content (a legitimate empty file's first write) and must not be treated // as missing. Falsy-but-present values (0, false, '') all count as present; @@ -608,6 +654,7 @@ export function validateAddParams(params: AddParams): void { */ export function validateUpdateParams(params: UpdateParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'update()') + rejectOversizeIndexArrays(params.metadata as Record | undefined, 'update()') // Same absent-vs-empty distinction as validateAddParams: '' is a real new // value (e.g. truncating a file to empty content via overwrite), only // null/undefined means "no new data was given". @@ -682,6 +729,7 @@ export function validateUpdateParams(params: UpdateParams): void { */ export function validateRelateParams(params: RelateParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'relate()') + rejectOversizeIndexArrays(params.metadata as Record | undefined, 'relate()') // 8.0 verb-id contract (L.7): verb ids are UUIDs, generated by brainy. // RelateParams has no `id` field — an untyped caller passing one would // previously have it silently ignored (a generated UUID was used instead). @@ -731,6 +779,7 @@ export function validateRelateParams(params: RelateParams): void { */ export function validateUpdateRelationParams(params: UpdateRelationParams): void { rejectForgedSystemKeys(params.metadata as Record | undefined, 'updateRelation()') + rejectOversizeIndexArrays(params.metadata as Record | undefined, 'updateRelation()') if (!params.id) { throw new Error('id is required for updateRelation') } diff --git a/tests/integration/metadata-vector-exclusion.test.ts b/tests/integration/metadata-vector-exclusion.test.ts index 9e11f9dc..0ca25388 100644 --- a/tests/integration/metadata-vector-exclusion.test.ts +++ b/tests/integration/metadata-vector-exclusion.test.ts @@ -26,6 +26,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { Brainy } from '../../src/brainy.js' import { NounType } from '../../src/types/graphTypes.js' import { existsSync, rmSync } from 'fs' +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../src/errors/brainyError.js' describe('Metadata Vector Exclusion Fix', () => { let brainy: Brainy @@ -155,29 +156,56 @@ describe('Metadata Vector Exclusion Fix', () => { expect(results[0].entity.metadata?.name).toBe('Bob') }) - it('should skip indexing large arrays (>10 elements)', async () => { - // Add entity with a large array (not a vector, just bulk data). + it('should REFUSE an array over the indexing bound, by name', async () => { + // A large array (not a vector, just bulk data). This used to be SKIPPED in + // 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}`) - await brainy.add({ - type: NounType.Document, - data: 'Doc with large array', - metadata: { - name: 'Doc with large array', - items: largeArray - } - }) + const err = await brainy + .add({ + type: NounType.Document, + data: 'Doc with large array', + metadata: { + name: 'Doc with large array', + items: largeArray + } + }) + .catch((e: any) => e) - // Large arrays (> 10 elements) are deliberately skipped to avoid indexing - // bulk/vector-like payloads: 'items' must NOT appear, and the 100 elements - // must NOT have produced 100 indexed fields. + expect(err).toBeInstanceOf(MetadataArrayTooLargeError) + expect(err.field).toBe('items') + 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 + // all no per-element numeric fields (the original explosion class). const fields = await brainy.getAvailableFields() expect(fields).not.toContain('items') const numericFields = fields.filter(f => /(^|\.)\d+$/.test(f)) expect(numericFields).toEqual([]) + }) - // The scalar 'name' field IS indexed. - expect(fields).toContain('name') + it('should index an array UP TO the bound — the old limit of 10 was the bug', async () => { + await brainy.add({ + type: NounType.Document, + data: 'Doc with a long-but-legitimate tag list', + metadata: { + name: 'Doc with many tags', + items: Array.from({ length: MAX_INDEXED_ARRAY_LENGTH }, (_, i) => `item${i}`) + } + }) + + const fields = await brainy.getAvailableFields() + // The field IS indexed now, and still without per-element numeric fields. + expect(fields).toContain('items') + expect(fields.filter(f => /(^|\.)\d+$/.test(f))).toEqual([]) + + // And the eleventh element — the one the old bound silently dropped the + // whole field for — really is searchable. + const hits = await brainy.find({ where: { items: 'item10' } }) + expect(hits.length).toBeGreaterThan(0) }) it('should preserve HNSW vector search functionality', async () => { diff --git a/tests/unit/utils/metadataIndex-array-bound.test.ts b/tests/unit/utils/metadataIndex-array-bound.test.ts new file mode 100644 index 00000000..cbbf6b63 --- /dev/null +++ b/tests/unit/utils/metadataIndex-array-bound.test.ts @@ -0,0 +1,242 @@ +/** + * @module tests/unit/utils/metadataIndex-array-bound + * @description THE INDEXABLE-ARRAY BOUND — a law with a name and a refusal, + * not a `continue`. + * + * THE DEFECT. An array-valued metadata field indexes one posting per element, + * so the index has always carried a ceiling. It was 10, and it was applied by a + * bare `continue` deep inside field extraction: + * + * if (Array.isArray(value) && value.length > 10) continue + * + * A row whose `tags` array held ELEVEN entries therefore had that field skipped + * entirely — no posting, no error, no warning. The row then failed to match + * every filtered search on `tags`, including `{ tags: 'a-tag-it-really-has' }`, + * 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, + * 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 + * 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. + * + * THE ONE PLACE THE BOUND STILL SKIPS is a row already on disk, written by an + * older engine under the old rule and read back by a rebuild, a catch-up fold + * or a remove. Refusing there would make an existing store un-rebuildable — so + * the row is admitted and the skipped field is NARRATED. Both sides are pinned. + */ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { Brainy } from '../../../src/brainy' +import { NounType, VerbType } from '../../../src/types/graphTypes' +import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError' +import { resolveEntityId } from '../../../src/utils/idNormalization' +import { prodLog } from '../../../src/utils/logger' + +/** `n` distinct scalar tags. */ +function tags(n: number, prefix = 't'): string[] { + return Array.from({ length: n }, (_, i) => `${prefix}${i}`) +} + +describe('the indexable-array bound', () => { + let brain: Brainy + + beforeEach(async () => { + brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + }) + + describe('BELOW the bound: the array indexes, every element of it', () => { + it('the eleven-element array that used to vanish is searchable', async () => { + // ELEVEN — one over the old silent limit, the whole shape of the defect. + await brain.add({ + id: 'eleven', + data: 'a row with eleven tags', + type: NounType.Document, + metadata: { tags: tags(11) }, + vector: [] + }) + + // Every element is a posting, including the eleventh. + for (const tag of tags(11)) { + const hits = await brain.find({ where: { tags: tag }, limit: 10 } as any) + expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('eleven')) + } + }) + + it('indexes right up to the bound — all 64 elements', async () => { + await brain.add({ + id: 'at-bound', + data: 'a row at the bound', + type: NounType.Document, + metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH) }, + vector: [] + }) + + // 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')) + } + }) + + it('a nested bag\'s array indexes under its dotted address', async () => { + await brain.add({ + id: 'nested', + data: 'a row with a nested tag list', + type: NounType.Document, + metadata: { facets: { labels: tags(20, 'l') } }, + vector: [] + }) + const hits = await brain.find({ where: { 'facets.labels': 'l19' }, limit: 10 } as any) + expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('nested')) + }) + }) + + describe('ABOVE the bound: the write is refused, by name', () => { + const OVER = MAX_INDEXED_ARRAY_LENGTH + 1 + + it('add() throws a typed error naming the field, the length and the bound', async () => { + const err = await brain + .add({ + id: 'too-many', + data: 'a row with too many tags', + type: NounType.Document, + metadata: { tags: tags(OVER) }, + vector: [] + } as any) + .catch((e: any) => e) + + expect(err).toBeInstanceOf(MetadataArrayTooLargeError) + expect(err.field).toBe('tags') + expect(err.length).toBe(OVER) + expect(err.limit).toBe(MAX_INDEXED_ARRAY_LENGTH) + expect(err.type).toBe('VALIDATION') + // The message carries all three, and names the cures. + expect(err.message).toContain('tags') + expect(err.message).toContain(String(OVER)) + expect(err.message).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) + expect(err.message).toContain('vector') + }) + + it('the refused row is not written at all — no half-indexed ghost', async () => { + await expect( + brain.add({ + id: 'refused', + data: 'refused', + type: NounType.Document, + metadata: { tags: tags(OVER) }, + vector: [] + } as any) + ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) + + expect(await brain.get('refused')).toBeNull() + const hits = await brain.find({ where: { tags: 't0' }, limit: 10 } as any) + expect(hits.map((r: any) => r.id)).not.toContain(resolveEntityId('refused')) + }) + + it('a 384-float embedding parked in the metadata bag is refused, not swallowed', async () => { + const err = await brain + .add({ + id: 'bag-vector', + data: 'an embedding in the wrong place', + type: NounType.Document, + metadata: { embedding: Array.from({ length: 384 }, (_, i) => i / 384) }, + vector: [] + } as any) + .catch((e: any) => e) + + expect(err).toBeInstanceOf(MetadataArrayTooLargeError) + expect(err.field).toBe('embedding') + expect(err.length).toBe(384) + }) + + it('update() refuses it too', async () => { + await brain.add({ + id: 'grow', + data: 'starts small', + type: NounType.Document, + metadata: { tags: tags(3) }, + vector: [] + }) + await expect( + brain.update({ id: 'grow', metadata: { tags: tags(OVER) } } as any) + ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) + + // And the row keeps the values it had. + const hits = await brain.find({ where: { tags: 't1' }, limit: 10 } as any) + expect(hits.map((r: any) => r.id)).toContain(resolveEntityId('grow')) + }) + + it('relate() refuses it on a verb\'s metadata', async () => { + await brain.add({ id: 'a', data: 'a', type: NounType.Thing, vector: [] }) + await brain.add({ id: 'b', data: 'b', type: NounType.Thing, vector: [] }) + await expect( + brain.relate({ + from: 'a', + to: 'b', + type: VerbType.RelatedTo, + metadata: { tags: tags(OVER) } + } as any) + ).rejects.toBeInstanceOf(MetadataArrayTooLargeError) + }) + + it('a nested oversize array is refused under its dotted address', async () => { + const err = await brain + .add({ + id: 'nested-over', + data: 'nested and too long', + type: NounType.Document, + metadata: { facets: { labels: tags(OVER, 'l') } }, + vector: [] + } as any) + .catch((e: any) => e) + expect(err).toBeInstanceOf(MetadataArrayTooLargeError) + expect(err.field).toBe('facets.labels') + }) + }) + + describe('a row already on disk is admitted, and the skip is NARRATED', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('extraction over an old oversize row warns by field, length and bound', async () => { + const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) + const index = (brain as any).metadataIndex + + // The shape an older engine persisted: the write door never saw it, so + // this reaches extraction directly — exactly as a rebuild or a remove + // reading the row back would. + const fields = index.extractIndexableFields({ + metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH + 5), keep: 'me' } + }) + + // The oversize field contributes nothing... + expect(fields.filter((f: any) => f.field === 'tags')).toHaveLength(0) + // ...the rest of the row indexes normally — the row is not rejected... + expect(fields.some((f: any) => f.field === 'keep' && f.value === 'me')).toBe(true) + // ...and the skip is said out loud, with everything needed to act on it. + expect(warn).toHaveBeenCalled() + const said = warn.mock.calls.map((c: any[]) => String(c[0])).join('\n') + expect(said).toContain('tags') + expect(said).toContain(String(MAX_INDEXED_ARRAY_LENGTH + 5)) + expect(said).toContain(String(MAX_INDEXED_ARRAY_LENGTH)) + expect(said).toContain('NOT indexed') + }) + + it('an at-bound row on disk is indexed in full and says nothing', async () => { + const warn = vi.spyOn(prodLog, 'warn').mockImplementation(() => {}) + const index = (brain as any).metadataIndex + + const fields = index.extractIndexableFields({ + metadata: { tags: tags(MAX_INDEXED_ARRAY_LENGTH) } + }) + expect(fields.filter((f: any) => f.field === 'tags')).toHaveLength(MAX_INDEXED_ARRAY_LENGTH) + + const said = warn.mock.calls.map((c: any[]) => String(c[0])).join('\n') + expect(said).not.toContain('indexing bound') + }) + }) +})