This repository has been archived on 2026-09-03. You can view files and clone it, but you cannot make any changes to it's state, such as pushing and creating new issues, pull requests or comments.
open-brainy/tests/unit/brainy/update.test.ts
David Snelling d7444ae804
Some checks are pending
CI / Node 24 (push) Waiting to run
CI / Node 22 (push) Waiting to run
CI / Integration + conformance (Node 22) (push) Waiting to run
CI / Bun (latest) (push) Waiting to run
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

577 lines
No EOL
18 KiB
TypeScript

/**
* 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'
import { MetadataArrayTooLargeError, MAX_INDEXED_ARRAY_LENGTH } from '../../../src/errors/brainyError'
import {
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'
}))
// v5.11.1: Need includeVectors to check vectors
const original = await brain.get(id, { includeVectors: true })
const originalVector = original!.vector
// Act - Update with new data triggers re-embedding
await brain.update({
id,
data: 'Completely different text content'
})
// Assert
const updated = await brain.get(id, { includeVectors: true })
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'
}))
// v5.11.1: Need includeVectors to check vectors
const original = await brain.get(id, { includeVectors: true })
// Act
await brain.update({
id,
data: 'Completely different text'
})
// Assert
// v5.11.1: Need includeVectors to check vectors
const updated = await brain.get(id, { includeVectors: true })
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()
})
it('should reject invalid entity type on update', async () => {
// Arrange
const id = await brain.add(createAddParams({
data: 'Test',
type: 'thing'
}))
// Act & Assert - Should properly validate type
await expect(brain.update({
id,
type: 'invalid_type' as any
})).rejects.toThrow('invalid NounType')
})
it('applies an explicit pre-computed vector (UpdateParams.vector contract)', async () => {
// Arrange
const id = await brain.add(createAddParams({
data: 'Test',
type: 'thing'
}))
const original = await brain.get(id, { includeVectors: true })
const originalVector = original!.vector
// Create a properly dimensioned but different vector
const differentVector = originalVector.map(v => v * 2)
// 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).
await brain.update({
id,
vector: differentVector
})
// Assert — the stored vector is the supplied one.
const updated = await brain.get(id, { includeVectors: true })
expect(updated).not.toBeNull()
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?/)
})
it('should reject empty update parameters', async () => {
// Arrange
const id = await brain.add(createAddParams({
data: 'Test',
type: 'thing',
metadata: { original: true }
}))
// 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')
})
})
describe('edge cases', () => {
it('should reject updating with null metadata', async () => {
// Arrange
const id = await brain.add(createAddParams({
data: 'Test',
type: 'thing',
metadata: { existing: 'data', another: 'field' }
}))
// Act & Assert - null metadata is not a valid update
// This prevents accidental data loss from null values
await expect(brain.update({
id,
metadata: null as any,
merge: false
})).rejects.toThrow('must specify at least one field to update')
// Verify original data is untouched
const entity = await brain.get(id)
expect(entity!.metadata.existing).toBe('data')
expect(entity!.metadata.another).toBe('field')
})
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)
})
// 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 = {
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 — the payload comes back whole, first element to last
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(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 () => {
// Arrange
const customId = '00000000-0000-0000-0000-000000000002'
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
// Assert
const opsPerSecond = (100 / duration) * 1000
expect(opsPerSecond).toBeGreaterThan(40) // v5.4.0: Type-first storage with metadata (realistic: 40+ ops/sec)
// 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')
})
})
})