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
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.
577 lines
No EOL
18 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
}) |