2025-09-11 16:23:32 -07:00
/ * *
* Unit tests for Brainy . update ( ) method
* Tests all aspects of updating entities in the neural database
* /
import { describe , it , expect , beforeEach , afterEach } from 'vitest'
import { Brainy } from '../../../src/brainy'
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
import { MetadataArrayTooLargeError , MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError'
import {
2025-09-11 16:23:32 -07:00
createAddParams ,
createTestConfig ,
} from '../../helpers/test-factory'
import {
assertCompletesWithin ,
} from '../../helpers/test-assertions'
describe ( 'Brainy.update()' , ( ) = > {
let brain : Brainy
beforeEach ( async ( ) = > {
brain = new Brainy ( createTestConfig ( ) )
await brain . init ( )
} )
afterEach ( async ( ) = > {
await brain . close ( )
} )
describe ( 'success paths' , ( ) = > {
it ( 'should update entity metadata' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Original content' ,
type : 'document' ,
metadata : { version : 1 , status : 'draft' }
} ) )
// Act
await brain . update ( {
id ,
metadata : { version : 2 , status : 'published' } ,
merge : false
} )
// Assert
const updated = await brain . get ( id )
expect ( updated ) . not . toBeNull ( )
expect ( updated ! . metadata . version ) . toBe ( 2 )
expect ( updated ! . metadata . status ) . toBe ( 'published' )
} )
it ( 'should merge metadata when merge is true' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Test content' ,
type : 'thing' ,
metadata : {
name : 'Original' ,
count : 10 ,
tags : [ 'original' ]
}
} ) )
// Act
await brain . update ( {
id ,
metadata : {
count : 20 ,
tags : [ 'updated' ] ,
newField : 'added'
} ,
merge : true
} )
// Assert
const updated = await brain . get ( id )
expect ( updated ) . not . toBeNull ( )
expect ( updated ! . metadata . name ) . toBe ( 'Original' ) // Preserved
expect ( updated ! . metadata . count ) . toBe ( 20 ) // Updated
expect ( updated ! . metadata . tags ) . toEqual ( [ 'updated' ] ) // Replaced
expect ( updated ! . metadata . newField ) . toBe ( 'added' ) // Added
} )
it ( 'should replace metadata when merge is false' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Test content' ,
type : 'thing' ,
metadata : {
name : 'Original' ,
count : 10 ,
willBeRemoved : true
}
} ) )
// Act
await brain . update ( {
id ,
metadata : {
newData : 'replaced' ,
count : 99
} ,
merge : false
} )
// Assert
const updated = await brain . get ( id )
expect ( updated ) . not . toBeNull ( )
expect ( updated ! . metadata . newData ) . toBe ( 'replaced' )
expect ( updated ! . metadata . count ) . toBe ( 99 )
expect ( updated ! . metadata . name ) . toBeUndefined ( ) // Removed
expect ( updated ! . metadata . willBeRemoved ) . toBeUndefined ( ) // Removed
} )
it ( 'should update entity type' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Versatile content' ,
type : 'thing' ,
metadata : { original : true }
} ) )
// Act
await brain . update ( {
id ,
type : 'document'
} )
// Assert
const updated = await brain . get ( id )
expect ( updated ) . not . toBeNull ( )
expect ( updated ! . type ) . toBe ( 'document' )
expect ( updated ! . metadata . original ) . toBe ( true ) // Metadata preserved
} )
it ( 'should update entity vector when data changes' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Original text content' ,
type : 'thing'
} ) )
2025-11-18 15:41:57 -08:00
// v5.11.1: Need includeVectors to check vectors
const original = await brain . get ( id , { includeVectors : true } )
2025-09-11 16:23:32 -07:00
const originalVector = original ! . vector
2025-11-18 15:41:57 -08:00
2025-09-11 16:23:32 -07:00
// Act - Update with new data triggers re-embedding
await brain . update ( {
id ,
data : 'Completely different text content'
} )
2025-11-18 15:41:57 -08:00
2025-09-11 16:23:32 -07:00
// Assert
2025-11-18 15:41:57 -08:00
const updated = await brain . get ( id , { includeVectors : true } )
2025-09-11 16:23:32 -07:00
expect ( updated ) . not . toBeNull ( )
// Vector should be different after re-embedding
expect ( updated ! . vector ) . not . toEqual ( originalVector )
expect ( updated ! . vector . length ) . toBe ( originalVector . length )
} )
it ( 'should re-embed when data is updated' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Original text' ,
type : 'document'
} ) )
2026-01-05 16:56:35 -08:00
// v5.11.1: Need includeVectors to check vectors
const original = await brain . get ( id , { includeVectors : true } )
2025-09-11 16:23:32 -07:00
// Act
await brain . update ( {
id ,
data : 'Completely different text'
} )
2026-01-05 16:56:35 -08:00
2025-09-11 16:23:32 -07:00
// Assert
2026-01-05 16:56:35 -08:00
// v5.11.1: Need includeVectors to check vectors
const updated = await brain . get ( id , { includeVectors : true } )
2025-09-11 16:23:32 -07:00
expect ( updated ) . not . toBeNull ( )
// Vector should be different after re-embedding
expect ( updated ! . vector ) . not . toEqual ( original ! . vector )
} )
it ( 'should update timestamps' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Timestamp test' ,
type : 'thing'
} ) )
const original = await brain . get ( id )
const originalUpdatedAt = original ! . updatedAt || original ! . createdAt
// Wait a bit to ensure timestamp difference
await new Promise ( resolve = > setTimeout ( resolve , 10 ) )
// Act
await brain . update ( {
id ,
metadata : { updated : true }
} )
// Assert
const updated = await brain . get ( id )
expect ( updated ) . not . toBeNull ( )
expect ( updated ! . createdAt ) . toBe ( original ! . createdAt ) // Created stays same
expect ( updated ! . updatedAt ) . toBeGreaterThan ( originalUpdatedAt )
} )
it ( 'should handle multiple updates to same entity' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Multi-update test' ,
type : 'thing' ,
metadata : { version : 1 }
} ) )
// Act - Multiple sequential updates
await brain . update ( { id , metadata : { version : 2 } , merge : true } )
await brain . update ( { id , metadata : { version : 3 } , merge : true } )
await brain . update ( { id , metadata : { version : 4 } , merge : true } )
// Assert
const final = await brain . get ( id )
expect ( final ) . not . toBeNull ( )
expect ( final ! . metadata . version ) . toBe ( 4 )
} )
} )
describe ( 'error paths' , ( ) = > {
it ( 'should handle updating non-existent entity' , async ( ) = > {
// Arrange
const fakeId = 'non-existent-12345'
// Act & Assert
await expect ( brain . update ( {
id : fakeId ,
metadata : { test : 'value' }
} ) ) . rejects . toThrow ( )
} )
2025-09-12 14:37:39 -07:00
it ( 'should reject invalid entity type on update' , async ( ) = > {
2025-09-11 16:23:32 -07:00
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Test' ,
type : 'thing'
} ) )
2025-09-12 14:37:39 -07:00
// Act & Assert - Should properly validate type
await expect ( brain . update ( {
2025-09-11 16:23:32 -07:00
id ,
type : 'invalid_type' as any
2025-09-12 14:37:39 -07:00
} ) ) . rejects . toThrow ( 'invalid NounType' )
2025-09-11 16:23:32 -07:00
} )
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
it ( 'applies an explicit pre-computed vector (UpdateParams.vector contract)' , async ( ) = > {
2025-09-11 16:23:32 -07:00
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Test' ,
type : 'thing'
} ) )
2026-01-05 16:56:35 -08:00
const original = await brain . get ( id , { includeVectors : true } )
2025-09-11 16:23:32 -07:00
const originalVector = original ! . vector
2026-01-05 16:56:35 -08:00
2025-09-11 16:23:32 -07:00
// Create a properly dimensioned but different vector
const differentVector = originalVector . map ( v = > v * 2 )
2026-01-05 16:56:35 -08:00
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
// Act — update with an explicit vector and no new data. The
// UpdateParams contract ("New pre-computed vector") applies it
// directly, with no re-embedding (mirrored by transact update ops).
2025-09-11 16:23:32 -07:00
await brain . update ( {
id ,
vector : differentVector
} )
2026-01-05 16:56:35 -08:00
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
// Assert — the stored vector is the supplied one.
2026-01-05 16:56:35 -08:00
const updated = await brain . get ( id , { includeVectors : true } )
2025-09-11 16:23:32 -07:00
expect ( updated ) . not . toBeNull ( )
feat(8.0): full query surface at historical generations via ephemeral index materialization
Historical Db values (now()/asOf() pins that history has moved past) now
serve the COMPLETE query surface - vector/hybrid search, graph traversal,
cursor pagination, and aggregation - by materializing ephemeral in-memory
indexes over the exact at-generation record set. The historical-query
throw is gone; NotYetSupportedAtHistoricalGenerationError is deleted.
Materializer (Brainy.materializeAtGeneration):
- Copies the at-G record set (live bytes for ids untouched since the pin,
immutable before-images otherwise) into a fresh MemoryStorage; a final
reconciliation pass under the commit mutex makes the copy exact even
when transactions commit mid-build.
- Opens a read-only Brainy over the copy: init rebuilds the metadata and
graph-adjacency indexes from the records; the vector index is built by
inserting every at-G vector (the at-G HNSW graph never existed on disk,
so there is nothing to restore). Host embedder and aggregate definitions
are shared - no second model load, aggregates backfill at-G values.
- Cost is the documented contract: O(n at G) time and memory, ONCE per Db
(handle cached; freed by release(), with a FinalizationRegistry backstop
that also closes leaked readers). A native VersionedIndexProvider serves
the same reads from retained segments with no rebuild.
Db routing (src/db/db.ts): metadata-level find()/related() keep the free
record path; index-only dimensions (query/vector/near/connected/cursor/
aggregate/includeRelations/non-metadata modes) route to the cached
materialization; unsupported where-operators on the record path re-route
there too instead of erroring. Speculative with() overlays keep the one
honest boundary - SpeculativeOverlayError (overlay entities carry no
embeddings, so index reads over them would be silently incomplete);
metadata find()/get()/filter related() work on overlays.
UpdateParams.vector contract now honored: an explicit pre-computed vector
applies directly (with dimension validation) in update() and transact
update ops, re-indexing HNSW - previously it was silently ignored unless
data also changed.
GraphAdjacencyIndex: adjacency now derives from the two verb-id LSM trees
filtered through the live-verb tombstone set (entity->entity edge trees
deleted - they carried no verb ids, so removeVerb could never tombstone
them and traversal served stale neighbors forever). Neighbor reads batch-
load live verbs via the unified cache; addVerb seeds the cache.
Proofs (tests/integration/db-mvcc.test.ts, 24 green): historical vector
search finds old vector placement including since-deleted entities;
historical graph traversal walks the old wiring after a rewire; historical
aggregation computes at-G group values; asOf() pins get the same surface;
the materialization builds once per Db and release() closes the ephemeral
reader (it refuses reads afterwards); overlays throw the documented error.
ADR-001 updated to the no-throws historical model.
2026-06-11 08:12:11 -07:00
expect ( updated ! . vector ) . toEqual ( differentVector )
} )
it ( 'rejects an explicit vector with mismatched dimensions' , async ( ) = > {
const id = await brain . add ( createAddParams ( {
data : 'Test' ,
type : 'thing'
} ) )
// Param validation rejects wrong dimensionality before the update runs
// (update() also re-checks against the store's actual dimensionality).
await expect (
brain . update ( { id , vector : [ 0.1 , 0.2 , 0.3 ] } )
) . rejects . toThrow ( /dimensions?/ )
2025-09-11 16:23:32 -07:00
} )
2025-09-12 14:37:39 -07:00
it ( 'should reject empty update parameters' , async ( ) = > {
2025-09-11 16:23:32 -07:00
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Test' ,
type : 'thing' ,
metadata : { original : true }
} ) )
2025-09-12 14:37:39 -07:00
// Act & Assert - Should require at least one field to update
await expect ( brain . update ( { id } ) ) . rejects . toThrow ( 'must specify at least one field to update' )
2025-09-11 16:23:32 -07:00
} )
} )
describe ( 'edge cases' , ( ) = > {
2025-09-12 14:37:39 -07:00
it ( 'should reject updating with null metadata' , async ( ) = > {
2025-09-11 16:23:32 -07:00
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Test' ,
type : 'thing' ,
metadata : { existing : 'data' , another : 'field' }
} ) )
2025-09-12 14:37:39 -07:00
// Act & Assert - null metadata is not a valid update
// This prevents accidental data loss from null values
await expect ( brain . update ( {
2025-09-11 16:23:32 -07:00
id ,
metadata : null as any ,
merge : false
2025-09-12 14:37:39 -07:00
} ) ) . rejects . toThrow ( 'must specify at least one field to update' )
2025-09-11 16:23:32 -07:00
2025-09-12 14:37:39 -07:00
// Verify original data is untouched
const entity = await brain . get ( id )
expect ( entity ! . metadata . existing ) . toBe ( 'data' )
expect ( entity ! . metadata . another ) . toBe ( 'field' )
2025-09-11 16:23:32 -07:00
} )
it ( 'should handle concurrent updates' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Concurrent test' ,
type : 'thing' ,
metadata : { counter : 0 }
} ) )
// Act - Fire 10 concurrent updates
const updates = Array . from ( { length : 10 } , ( _ , i ) = >
brain . update ( {
id ,
metadata : { counter : i + 1 } ,
merge : false
} )
)
await Promise . all ( updates )
// Assert - Last update wins
const final = await brain . get ( id )
expect ( final ) . not . toBeNull ( )
expect ( final ! . metadata . counter ) . toBeGreaterThan ( 0 )
expect ( final ! . metadata . counter ) . toBeLessThanOrEqual ( 10 )
} )
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
// 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 ( ) = > {
2025-09-11 16:23:32 -07:00
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Large metadata test' ,
type : 'thing'
} ) )
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
// Large in every dimension EXCEPT array length: a long string, many
// fields, deep nesting, and an array sitting exactly ON the bound.
2025-09-11 16:23:32 -07:00
const largeMetadata = {
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
atTheBound : Array.from ( { length : MAX_INDEXED_ARRAY_LENGTH } , ( _ , i ) = > ` item ${ i } ` ) ,
2025-09-11 16:23:32 -07:00
bigObject : Object.fromEntries (
Array . from ( { length : 100 } , ( _ , i ) = > [ ` key ${ i } ` , ` value ${ i } ` ] )
) ,
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
longString : 'x' . repeat ( 10 _000 ) ,
2025-09-11 16:23:32 -07:00
deepNesting : Array ( 10 ) . fill ( null ) . reduce (
( acc ) = > ( { nested : acc } ) ,
{ value : 'deep' }
)
}
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
2025-09-11 16:23:32 -07:00
// Act
await brain . update ( {
id ,
metadata : largeMetadata ,
merge : false
} )
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
// Assert — the payload comes back whole, first element to last
2025-09-11 16:23:32 -07:00
const updated = await brain . get ( id )
expect ( updated ) . not . toBeNull ( )
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
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 } ` )
2025-09-11 16:23:32 -07:00
expect ( Object . keys ( updated ! . metadata . bigObject ) ) . toHaveLength ( 100 )
test(metadata): the three large-metadata cases pin the bound, not a magic length
get(), relate() and update() each carried a "very large metadata" case that
parked an array of 1000 (or 100) elements in the metadata bag and asserted it
came back. The indexable-array bound refuses that shape at the write door now
— an array field mints one posting per element, so an unbounded array is an
unbounded write — and the three cases were failing on the refusal they should
have been pinning.
Each is rewritten to the law that replaced it, in two halves:
- a large SCALAR payload still round-trips whole through the door: a
10,000-character string, 100 sibling fields, a ten-deep nest walked to the
bottom, and an array sitting exactly ON the bound, checked first element
to last;
- an array one element OVER the bound refuses with MetadataArrayTooLargeError
carrying the field, the length and the bound, on the error object AND in
the message. update()'s refusal additionally proves the row is unchanged,
and relate()'s that no relation was written — refused means not written,
not written-then-skipped.
Every length is derived from the imported MAX_INDEXED_ARRAY_LENGTH; none is
typed as a number. That is what made the old cases fragile: 100 read as "over
the bound" and 1000 as "large", and both meanings changed under them when the
constant moved. These follow the constant instead.
2026-09-02 15:41:21 -07:00
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 ( )
2025-09-11 16:23:32 -07:00
} )
it ( 'should preserve entity ID during update' , async ( ) = > {
// Arrange
2025-11-02 11:44:32 -08:00
const customId = '00000000-0000-0000-0000-000000000002'
2025-09-11 16:23:32 -07:00
await brain . add ( createAddParams ( {
id : customId ,
data : 'Test' ,
type : 'thing'
} ) )
// Act
await brain . update ( {
id : customId ,
data : 'Updated content' ,
type : 'document' ,
metadata : { changed : true }
} )
// Assert
const updated = await brain . get ( customId )
expect ( updated ) . not . toBeNull ( )
expect ( updated ! . id ) . toBe ( customId )
expect ( updated ! . type ) . toBe ( 'document' )
expect ( updated ! . metadata . changed ) . toBe ( true )
} )
} )
describe ( 'performance' , ( ) = > {
it ( 'should update entities quickly' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Performance test' ,
type : 'thing'
} ) )
// Act & Assert
await assertCompletesWithin (
( ) = > brain . update ( {
id ,
metadata : { updated : true }
} ) ,
100 , // Should complete within 100ms
'Update operation'
)
} )
it ( 'should handle batch updates efficiently' , async ( ) = > {
// Arrange - Create 100 entities
const ids : string [ ] = [ ]
for ( let i = 0 ; i < 100 ; i ++ ) {
const id = await brain . add ( createAddParams ( {
data : ` Entity ${ i } ` ,
type : 'thing' ,
metadata : { index : i }
} ) )
ids . push ( id )
}
// Act - Update all entities
const start = performance . now ( )
const updates = ids . map ( ( id , i ) = >
brain . update ( {
id ,
metadata : { index : i , updated : true } ,
merge : true
} )
)
await Promise . all ( updates )
const duration = performance . now ( ) - start
2025-11-05 17:01:44 -08:00
2025-09-11 16:23:32 -07:00
// Assert
const opsPerSecond = ( 100 / duration ) * 1000
2025-11-05 17:01:44 -08:00
expect ( opsPerSecond ) . toBeGreaterThan ( 40 ) // v5.4.0: Type-first storage with metadata (realistic: 40+ ops/sec)
2025-09-11 16:23:32 -07:00
// Verify updates
const entity = await brain . get ( ids [ 0 ] )
expect ( entity ! . metadata . updated ) . toBe ( true )
} )
} )
describe ( 'consistency' , ( ) = > {
it ( 'should maintain consistency after update' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Consistency test' ,
type : 'thing' ,
metadata : { important : 'data' , version : 1 }
} ) )
// Act
await brain . update ( {
id ,
metadata : { version : 2 } ,
merge : true
} )
// Assert - Multiple gets should return same updated data
const get1 = await brain . get ( id )
const get2 = await brain . get ( id )
expect ( get1 ) . not . toBeNull ( )
expect ( get2 ) . not . toBeNull ( )
expect ( get1 ! . metadata . version ) . toBe ( 2 )
expect ( get2 ! . metadata . version ) . toBe ( 2 )
expect ( get1 ! . metadata . important ) . toBe ( 'data' ) // Preserved
expect ( get2 ! . metadata . important ) . toBe ( 'data' ) // Preserved
} )
it ( 'should reflect updates in vector search' , async ( ) = > {
// Arrange
const id = await brain . add ( createAddParams ( {
data : 'Original searchable content' ,
type : 'document' ,
metadata : { category : 'original' }
} ) )
// Act
await brain . update ( {
id ,
data : 'Updated searchable content' ,
metadata : { category : 'updated' } ,
merge : false
} )
// Assert - Vector search should find the updated entity
const results = await brain . find ( {
query : 'Updated searchable content' ,
limit : 10
} )
const found = results . find ( r = > r . id === id )
expect ( found ) . toBeDefined ( )
expect ( found ! . entity . metadata . category ) . toBe ( 'updated' )
} )
} )
} )