brainy/tests/regression/metadata-index-cleanup.unit.test.ts

329 lines
13 KiB
TypeScript
Raw Normal View History

fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
/**
* Regression tests: metadata index cleanup after remove / removeMany
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
*
* Bug report (wickworks): brain.removeMany() was not removing metadata index
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
* entries for deleted entities. The same defect also existed in delete().
*
* Root causes fixed:
*
* 1. idMapper not cleaned up EntityIdMapper accumulated all entity UUIDint
* mappings and never removed them on deletion. idMapper.getAllIntIds() is used
* as the "universe" for `ne` and `exists:false` operators, so deleted entities
* continued to appear in those query results forever.
* Fix: removeFromIndex() now calls idMapper.remove(id) + idMapper.flush()
* AFTER all bitmap operations complete.
*
* 2. Optional fields indexed as __NULL__ but never unindexed entityForIndexing
* in add() included confidence/weight/createdBy as explicit keys even when
* undefined. Object.entries() preserves keys with undefined values, so
* extractIndexableFields() indexed them as '__NULL__'. storageMetadata omitted
* those keys entirely via conditional spreading, so removeFromIndex() never
* cleaned up those bitmap entries.
* Fix: entityForIndexing now uses the same conditional spreading pattern as
* storageMetadata for confidence, weight, and createdBy.
*
* 3. result.successful updated inside transaction builder removeMany() pushed
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
* ids to result.successful during the builder phase, before transaction.execute()
* ran. A transaction rollback would leave result.successful containing ids that
* were never actually deleted.
* Fix: queued ids are held in a local array and moved to result.successful only
* after executeTransaction() resolves without error.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
// 384 dimensions matches the default WASM embedding model loaded during brain.init().
// Passing an explicit vector skips AI embedding while remaining dimension-compatible.
const DIM = 384
const makeVec = (seed = 1) =>
new Float32Array(DIM).map((_, i) => ((i + seed) % DIM) / DIM)
describe('Metadata index cleanup after remove / removeMany', () => {
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
let brain: Brainy
beforeEach(async () => {
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1) Brainy 8.0 makes subtype required by default on every public write path (`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`, import). Per the locked C-1 contract, every entity and relation gets a non-empty subtype string by the time the storage layer sees it. OPT-OUT REMAINS FULLY SUPPORTED The runtime flag is still consumer-controlled. Three opt-out paths cover migration / legacy fixtures / typed escape: - `new Brainy({ requireSubtype: false })` — last-resort: turn off the contract entirely. Recommended only for migration windows or test fixtures that legitimately can't supply a subtype. - `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` — per-type allowlist: strict everywhere except the listed types. - `brain.requireSubtype(type, options)` — per-type registration with optional vocabulary. Composes with the brain-wide flag. Default is now `true`. Opt-out is explicit and documented; nothing silently degrades. TEST SWEEP Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call site across 120 test files. Three sed patterns covered the shapes: - `new Brainy({` → `new Brainy({ requireSubtype: false,` - `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,` - `new Brainy()` → `new Brainy({ requireSubtype: false })` tests/helpers/test-factory.ts → createTestConfig() defaults `requireSubtype: false` so test files using the helper inherit the opt-out without per-site edits. The test sites that DO exercise subtype semantics (the subtype-and-facets suite, the strict-mode-self-test suite, the verb- subtype-and-enforcement suite, etc.) already pass real subtypes — they were the 7.30.x acceptance tests for this contract. Those tests continue to pass unchanged. CHANGES src/brainy.ts - normalizeConfig() — `requireSubtype` default `false` → `true`. Comment refreshed to document the three opt-out paths. tests/* (120 files) - Bulk-edited brain construction sites. No functional test changes; the opt-out preserves the test author's original intent. tests/helpers/test-factory.ts - createTestConfig() base config gains `requireSubtype: false`. NO-OP for consumers who were already passing subtype on every write. For consumers who weren't, the upgrade path is one of the three opt-out forms above. Migration recipe documented in 8.0 release notes (next commit). VERIFICATION - npx tsc --noEmit: clean - npm test: 1408 / 1409 (same pre-existing race-condition outstanding; no other regressions from the flip)
2026-06-09 14:58:25 -07:00
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
await brain.init()
})
afterEach(async () => {
await brain.close()
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function addEntity(opts: {
type?: string
service?: string
metadata?: Record<string, unknown>
confidence?: number
weight?: number
} = {}): Promise<string> {
return brain.add({
data: 'test',
vector: makeVec(),
type: (opts.type ?? 'thing') as any,
...(opts.service !== undefined && { service: opts.service }),
...(opts.metadata !== undefined && { metadata: opts.metadata }),
...(opts.confidence !== undefined && { confidence: opts.confidence }),
...(opts.weight !== undefined && { weight: opts.weight }),
})
}
// ---------------------------------------------------------------------------
// delete() — single entity
// ---------------------------------------------------------------------------
describe('delete()', () => {
it('removes entity from type index so find({ type }) returns 0', async () => {
const id = await addEntity({ type: 'product' })
await brain.remove(id)
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ type: 'product' as any })
expect(results).toHaveLength(0)
})
it('removes entity from ne operator universe', async () => {
// Add one entity with service 'alpha', delete it.
// A ne:'beta' query should return 0, not the deleted entity.
const id = await addEntity({ service: 'alpha', type: 'thing' })
await brain.remove(id)
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ where: { service: { ne: 'beta' } } })
const ids = results.map(r => r.id)
expect(ids).not.toContain(id)
})
it('removes entity from exists:false results', async () => {
// Entity added without a custom 'archivedAt' field.
// After deletion it must not appear in an exists:false query.
const id = await addEntity({ type: 'thing' })
await brain.remove(id)
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ where: { archivedAt: { exists: false } } })
const ids = results.map(r => r.id)
expect(ids).not.toContain(id)
})
})
// ---------------------------------------------------------------------------
// removeMany() — batch deletion
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
// ---------------------------------------------------------------------------
describe('removeMany()', () => {
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
it('removes all entities from type index so find({ type }) returns 0', async () => {
const ids = await Promise.all([
addEntity({ type: 'concept' }),
addEntity({ type: 'concept' }),
addEntity({ type: 'concept' }),
])
await brain.removeMany({ ids })
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ type: 'concept' as any, limit: 50 })
expect(results).toHaveLength(0)
})
it('removes deleted entities from ne operator universe', async () => {
// Three entities with service:'alpha', two with service:'beta'.
// Delete the alpha ones. A ne:'beta' query must return 0, not the deleted alphas.
const alphaIds = await Promise.all([
addEntity({ service: 'alpha', type: 'thing' }),
addEntity({ service: 'alpha', type: 'thing' }),
addEntity({ service: 'alpha', type: 'thing' }),
])
const betaIds = await Promise.all([
addEntity({ service: 'beta', type: 'thing' }),
addEntity({ service: 'beta', type: 'thing' }),
])
await brain.removeMany({ ids: alphaIds })
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ where: { service: { ne: 'beta' } } })
const resultIds = results.map(r => r.id)
// Deleted alpha entities must not appear
for (const id of alphaIds) {
expect(resultIds).not.toContain(id)
}
// Surviving beta entities are outside the ne:'beta' exclusion so also absent — that's fine
// The key assertion is zero deleted entities in results
expect(resultIds.filter(id => alphaIds.includes(id))).toHaveLength(0)
void betaIds // referenced to document intent
})
it('removes deleted entities from exists:false results', async () => {
// Three entities WITHOUT a custom field 'closedAt'.
// After deletion they must not appear in an exists:false query.
const deletedIds = await Promise.all([
addEntity({ type: 'thing', metadata: { region: 'us' } }),
addEntity({ type: 'thing', metadata: { region: 'eu' } }),
addEntity({ type: 'thing', metadata: { region: 'ap' } }),
])
// One surviving entity also without closedAt — it SHOULD appear in results
const survivorId = await addEntity({ type: 'thing', metadata: { region: 'us', active: true } })
await brain.removeMany({ ids: deletedIds })
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ where: { closedAt: { exists: false } } })
const resultIds = results.map(r => r.id)
// Deleted entities must be absent
for (const id of deletedIds) {
expect(resultIds).not.toContain(id)
}
// The surviving entity without closedAt should still be findable
expect(resultIds).toContain(survivorId)
})
it('result.successful contains only ids whose deletions were committed', async () => {
const ids = await Promise.all([
addEntity({ type: 'thing' }),
addEntity({ type: 'thing' }),
addEntity({ type: 'thing' }),
])
const result = await brain.removeMany({ ids })
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
// All should succeed — verify successful list is correct
expect(result.successful).toHaveLength(ids.length)
expect(result.failed).toHaveLength(0)
for (const id of ids) {
expect(result.successful).toContain(id)
}
// Entities must actually be gone from storage
for (const id of ids) {
const entity = await brain.get(id)
expect(entity).toBeNull()
}
})
fix: one field-resolution law across aggregation hooks, source.where, removeMany, and find() spellings Four fixes from a consumer conformance report, one root disease — two field-resolution regimes where there must be one: - The delete/update aggregation hooks fed the engine a partial entity view (type/service/data/metadata only), so a reserved-field groupBy (subtype, visibility, ...) resolved to a nonexistent group on the way down: counts drifted upward forever after deletes, and updates moving an entity between reserved-field groups double-counted. The hooks now pass the full-fidelity view via entityForAggFromRawRecord (every reserved field top-level, mirroring the add path); the update sites pass the full get() view instead of a hand-rolled subset. - Aggregation source.where resolved fields only against the custom metadata bag, so where on a reserved field silently matched nothing. The matcher now resolves each filtered field through resolveEntityField — the same single source of truth groupBy uses. - removeMany() with no usable selector (bare array passed positionally, empty params, ids: []) resolved successfully having deleted nothing. All three now throw; the two legacy tests that pinned the silent no-op as 'graceful' now pin the refusal. - find() where keys accept both spellings: a metadata.-prefixed key falls back to its flattened spelling when the prefixed one is not indexed (metadata is flattened at index time). A literal nested custom key named metadata still wins when indexed as spelled. Five regression pins in aggregate-reserved-fields.test.ts (4 of 5 vary red on the unfixed code).
2026-07-19 10:54:36 -07:00
it('refuses an empty ids array loudly (a silent no-op is not "graceful")', async () => {
// 8.8.2: an empty selector used to resolve successfully having deleted
// NOTHING — the caller believed the delete happened. Now it throws.
await expect(brain.removeMany({ ids: [] })).rejects.toThrow(/ids: \[\]/)
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
})
it('handles large batch (> 1 chunk) without leaving stale index entries', async () => {
// 25 entities → 3 transaction chunks of 10, 10, 5
const ids = await Promise.all(
Array.from({ length: 25 }, (_, i) => addEntity({ type: 'document', metadata: { i } }))
)
const result = await brain.removeMany({ ids })
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
expect(result.successful).toHaveLength(25)
expect(result.failed).toHaveLength(0)
// No document should survive in the index
const remaining = await brain.find({ type: 'document' as any, limit: 100 })
expect(remaining).toHaveLength(0)
// No deleted entity should appear in a ne query
const neResults = await brain.find({ where: { i: { exists: true } } })
const leftoverIds = neResults.map(r => r.id).filter(id => ids.includes(id))
expect(leftoverIds).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// Optional-field indexing (Fix 2)
// ---------------------------------------------------------------------------
describe('optional field indexing', () => {
it('entity added without confidence does not appear in confidence:exists:true query', async () => {
// Without Fix 2, undefined confidence was indexed as __NULL__, making it appear
// as though the entity has a confidence value in the sparse index.
const noConfidenceId = await addEntity({ type: 'thing' })
const withConfidenceId = await addEntity({ type: 'thing', confidence: 0.9 })
const results = await brain.find({ where: { confidence: { exists: true } } })
const ids = results.map(r => r.id)
expect(ids).toContain(withConfidenceId)
expect(ids).not.toContain(noConfidenceId)
})
it('entity added without weight does not appear in weight:exists:true query', async () => {
const noWeightId = await addEntity({ type: 'thing' })
const withWeightId = await addEntity({ type: 'thing', weight: 0.5 })
const results = await brain.find({ where: { weight: { exists: true } } })
const ids = results.map(r => r.id)
expect(ids).toContain(withWeightId)
expect(ids).not.toContain(noWeightId)
})
it('deleting entity without optional fields leaves no orphaned __NULL__ entries', async () => {
// Add and delete an entity that has no confidence or weight.
// After deletion the confidence/__NULL__ and weight/__NULL__ bitmap entries
// (if any were created) must not be surfaced through any query.
const id = await addEntity({ type: 'thing' })
await brain.remove(id)
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
// Entity must not appear in any confidence query
const existsTrue = await brain.find({ where: { confidence: { exists: true } } })
expect(existsTrue.map(r => r.id)).not.toContain(id)
const existsFalse = await brain.find({ where: { confidence: { exists: false } } })
expect(existsFalse.map(r => r.id)).not.toContain(id)
})
})
// ---------------------------------------------------------------------------
// Survivors are unaffected
// ---------------------------------------------------------------------------
describe('partial removeMany does not affect surviving entities', () => {
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
it('surviving entities remain queryable after deleting others of the same type', async () => {
const toDelete = await Promise.all([
addEntity({ type: 'collection', metadata: { group: 'a' } }),
addEntity({ type: 'collection', metadata: { group: 'a' } }),
])
const survivors = await Promise.all([
addEntity({ type: 'collection', metadata: { group: 'b' } }),
addEntity({ type: 'collection', metadata: { group: 'b' } }),
addEntity({ type: 'collection', metadata: { group: 'b' } }),
])
await brain.removeMany({ ids: toDelete })
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ type: 'collection' as any, limit: 50 })
const resultIds = results.map(r => r.id)
// All survivors present
for (const id of survivors) {
expect(resultIds).toContain(id)
}
// Deleted entities absent
for (const id of toDelete) {
expect(resultIds).not.toContain(id)
}
})
it('delete() of one entity does not affect siblings in same type', async () => {
const a = await addEntity({ type: 'task', service: 'svc' })
const b = await addEntity({ type: 'task', service: 'svc' })
const c = await addEntity({ type: 'task', service: 'svc' })
await brain.remove(a)
fix: metadata index not cleaned up after delete/deleteMany Three bugs caused deleted entities to persist in the metadata index: 1. idMapper never cleaned up — EntityIdMapper accumulated UUID→int mappings permanently. idMapper.getAllIntIds() is used as the universe for ne and exists:false operators, so deleted entities returned in those queries indefinitely. Fix: removeFromIndex() now calls idMapper.remove(id) and idMapper.flush() after all bitmap operations complete (must be last because removeFromChunk() reads idMapper.getInt(id) internally). 2. Optional fields indexed as __NULL__ but never unindexed — entityForIndexing in add() included confidence, weight, and createdBy as explicit keys even when undefined. Object.entries() preserves undefined-valued keys so extractIndexableFields() indexed them as '__NULL__' bitmap entries. storageMetadata omitted those keys via conditional spreading, so removeFromIndex() passed a structure without those keys and never cleaned them up. Fix: entityForIndexing now uses conditional spreading for confidence, weight, and createdBy matching storageMetadata exactly. 3. result.successful updated before transaction commits — deleteMany() pushed ids to result.successful inside the transaction builder, before transaction.execute() ran. A rollback would leave result.successful containing ids that were never actually deleted. Fix: queued ids are held in a local chunkQueued array and moved to result.successful only after executeTransaction() resolves without throwing. Adds regression test suite (14 tests) covering delete() and deleteMany() for type-index cleanup, ne operator, exists:false operator, optional-field indexing, and partial deletion correctness. Reported by wickworks team.
2026-02-18 15:33:56 -08:00
const results = await brain.find({ type: 'task' as any, limit: 50 })
const ids = results.map(r => r.id)
expect(ids).not.toContain(a)
expect(ids).toContain(b)
expect(ids).toContain(c)
})
})
})