feat: stable EntityIdMapper — rebuild() no longer renumbers (2.4.0 #1, the foundation) #1

Merged
dpsifr merged 4 commits from feat/2.4.0-storage into main 2026-05-28 19:37:48 +02:00
5 changed files with 473 additions and 40 deletions
Showing only changes of commit 42cd241305 - Show all commits

View file

@ -37,8 +37,14 @@ import { setGlobalCache } from './utils/unifiedCache.js'
import type { UnifiedCache } from './utils/unifiedCache.js' import type { UnifiedCache } from './utils/unifiedCache.js'
import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js' import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js'
import { PluginRegistry } from './plugin.js' import { PluginRegistry } from './plugin.js'
import type { BrainyPlugin, BrainyPluginContext, VectorStoreMmapProvider } from './plugin.js' import type {
BrainyPlugin,
BrainyPluginContext,
GraphCompressionProvider,
VectorStoreMmapProvider
} from './plugin.js'
import { MmapVectorBackend } from './hnsw/mmapVectorBackend.js' import { MmapVectorBackend } from './hnsw/mmapVectorBackend.js'
import { ConnectionsCodec } from './hnsw/connectionsCodec.js'
import { TransactionManager } from './transaction/TransactionManager.js' import { TransactionManager } from './transaction/TransactionManager.js'
import { import {
ValidationConfig, ValidationConfig,
@ -569,6 +575,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// works, just without the zero-copy fast path. // works, just without the zero-copy fast path.
await this.wireMmapVectorBackend() await this.wireMmapVectorBackend()
// Wire the connections codec (2.4.0 #3). When the graph:compression
// provider is registered AND the metadata index exposes a stable
// idMapper, inject a codec that encodes HNSW connections as
// delta-varint blobs at save time and decodes on load. The blob
// primitive itself works on every brainy 7.25.0 adapter, so unlike the
// mmap-vector backend this layer engages even on cloud adapters.
this.wireConnectionsCodec()
// Rebuild indexes if needed for existing data // Rebuild indexes if needed for existing data
await this.rebuildIndexesIfNeeded() await this.rebuildIndexesIfNeeded()
@ -7792,6 +7806,35 @@ export class Brainy<T = any> implements BrainyInterface<T> {
} }
} }
/**
* @description Wire the HNSW connections codec (2.4.0 #3). Activates when
* BOTH (a) the `graph:compression` provider is registered (cortex registers
* `{ encode: encodeConnections, decode: decodeConnections }`), and (b) the
* metadata index exposes a stable idMapper. Failures are non-fatal: HNSW
* keeps working via the legacy JSON-array path.
*
* Unlike the mmap-vector backend, this layer does NOT require a real local
* path the binary-blob primitive saves the compressed bytes through the
* storage adapter directly, so the codec is engaged on cloud adapters too.
* Format convergence is lazy: pre-2.4.0 nodes load via the legacy path,
* then the next dirty save writes the compressed form (and the legacy
* field of saveHNSWData becomes empty), so all reads converge over time
* without an explicit migration step.
*/
private wireConnectionsCodec(): void {
const provider = this.pluginRegistry.getProvider<GraphCompressionProvider>('graph:compression')
if (!provider) return
const idMapper = this.metadataIndex.getIdMapper?.()
if (!idMapper) return
const codec = new ConnectionsCodec(provider, idMapper)
this.index.setConnectionsCodec(codec)
if (!this.config.silent) {
console.log('[brainy] graph link compression wired (delta-varint connections via graph:compression provider)')
}
}
/** /**
* Rebuild indexes from persisted data if needed (LAZY LOADING) * Rebuild indexes from persisted data if needed (LAZY LOADING)
* *

View file

@ -0,0 +1,137 @@
/**
* @module hnsw/connectionsCodec
* @description Bridge between brainy's UUID-keyed HNSW connection sets and
* cortex's int-keyed delta-varint encode/decode (the `graph:compression`
* provider). Translates UUIDs to stable int slots via the post-2.4.0 #1
* `EntityIdMapper`, batches all of a node's per-level connection lists into
* a single compact buffer, and reverses the path on load.
*
* Wire format of the per-node buffer the codec produces:
*
* [num_levels: u8]
* repeated num_levels times:
* [level: u8]
* [encoded_len: u32 LE]
* [encoded_bytes: <encoded_len> bytes] <- provider.encode(ints)
*
* Why a single buffer per node and not one blob per level:
* - One `saveBinaryBlob` call per persisted node, regardless of level count.
* On hot insert paths an HNSW node may be at levels 0..3; the per-node
* blob trades a one-byte count for 3 saved I/O round-trips.
* - Read path is symmetric: one `loadBinaryBlob` reconstructs the full
* connection set, so HNSW rebuild stays linear in the number of nodes.
*
* Loose UUIDs (ints assigned to entities that no longer exist) are silently
* dropped on decode. They can't legitimately participate in graph traversal
* because the entity is gone; treating them as `undefined` UUIDs matches the
* pre-2.4.0 behaviour of an `idMapper.getUuid()` miss in the legacy load path.
*/
import type {
EntityIdMapperProvider,
GraphCompressionProvider
} from '../plugin.js'
export class ConnectionsCodec {
constructor(
private readonly provider: GraphCompressionProvider,
private readonly idMapper: EntityIdMapperProvider
) {}
/**
* @description Encode all levels of a node's connections into one compact
* buffer. Empty input an empty buffer (zero levels, no body). The
* idMapper assigns stable ints on demand if a connection UUID has never
* been seen append-only, matches the rest of the 2.4.0 contract.
*/
encode(connectionsByLevel: Map<number, Set<string>>): Buffer {
if (connectionsByLevel.size === 0) {
// Single byte: zero levels. Round-trips cleanly through decode.
return Buffer.from([0])
}
if (connectionsByLevel.size > 0xff) {
throw new Error(
`ConnectionsCodec.encode: too many HNSW levels (${connectionsByLevel.size}); ` +
`the per-node header is one byte (max 255 levels).`
)
}
// First pass: encode each level into its own buffer.
const levelBuffers: Array<{ level: number; buf: Buffer }> = []
let bodyBytes = 0
for (const [level, uuids] of connectionsByLevel) {
if (level > 0xff) {
throw new Error(
`ConnectionsCodec.encode: level ${level} exceeds u8 header range (max 255).`
)
}
const ints: number[] = []
for (const uuid of uuids) {
ints.push(this.idMapper.getOrAssign(uuid))
}
const buf = this.provider.encode(ints)
levelBuffers.push({ level, buf })
bodyBytes += 1 + 4 + buf.length // level + len + payload
}
// Second pass: concatenate with header.
const out = Buffer.alloc(1 + bodyBytes)
out.writeUInt8(levelBuffers.length, 0)
let offset = 1
for (const { level, buf } of levelBuffers) {
out.writeUInt8(level, offset)
offset += 1
out.writeUInt32LE(buf.length, offset)
offset += 4
buf.copy(out, offset)
offset += buf.length
}
return out
}
/**
* @description Decode a per-node buffer back into a connections Map. Ints
* for entities the idMapper no longer knows are silently dropped see the
* module-level note on why.
*/
decode(data: Buffer): Map<number, Set<string>> {
const out = new Map<number, Set<string>>()
if (data.length === 0) return out
const count = data.readUInt8(0)
let offset = 1
for (let i = 0; i < count; i++) {
if (offset + 5 > data.length) {
throw new Error(`ConnectionsCodec.decode: truncated header at level ${i}`)
}
const level = data.readUInt8(offset)
offset += 1
const len = data.readUInt32LE(offset)
offset += 4
if (offset + len > data.length) {
throw new Error(`ConnectionsCodec.decode: truncated body at level ${level}`)
}
const levelBuf = data.slice(offset, offset + len)
offset += len
const ints = this.provider.decode(levelBuf)
const uuids = new Set<string>()
for (const intId of ints) {
const uuid = this.idMapper.getUuid(intId)
if (uuid !== undefined) uuids.add(uuid)
}
out.set(level, uuids)
}
return out
}
}
/**
* @description Build the binary-blob storage key for a node's compressed
* connections. Suffix-free; the adapter appends its own. Keys live under
* `_hnsw_conn/` so they don't collide with the existing `_column_index/`
* blobs and so a cortex-side reader knows exactly where to look.
*/
export function compressedConnectionsKey(nodeId: string): string {
return `_hnsw_conn/${nodeId}`
}

View file

@ -19,6 +19,7 @@ import { quantizeSQ8, distanceSQ8 } from '../utils/vectorQuantization.js'
import type { SQ8QuantizedVector } from '../utils/vectorQuantization.js' import type { SQ8QuantizedVector } from '../utils/vectorQuantization.js'
import type { HnswProvider } from '../plugin.js' import type { HnswProvider } from '../plugin.js'
import { MmapVectorBackend } from './mmapVectorBackend.js' import { MmapVectorBackend } from './mmapVectorBackend.js'
import { ConnectionsCodec, compressedConnectionsKey } from './connectionsCodec.js'
// Default HNSW parameters // Default HNSW parameters
const DEFAULT_CONFIG: HNSWConfig = { const DEFAULT_CONFIG: HNSWConfig = {
@ -57,6 +58,16 @@ export class HNSWIndex implements HnswProvider {
// Null on cloud storage adapters (no local path) and pre-injection init. // Null on cloud storage adapters (no local path) and pre-injection init.
private vectorBackend: MmapVectorBackend | null = null private vectorBackend: MmapVectorBackend | null = null
// Optional connections codec (2.4.0 #3). When set, node-persist writes the
// node's per-level connection sets as a single delta-varint-compressed
// binary blob (typically ~4× smaller than the legacy JSON-UUID-array
// shape), plus an empty `connections: {}` in saveHNSWData as the marker.
// The read path tries to load the blob first; missing blob → legacy
// connections field. Format convergence is lazy: pre-2.4.0 nodes still
// load via the legacy path, then write the compressed form on next dirty
// save, so the migration converges under live traffic with no big-bang.
private connectionsCodec: ConnectionsCodec | null = null
// COW (Copy-on-Write) support // COW (Copy-on-Write) support
private cowEnabled: boolean = false private cowEnabled: boolean = false
private cowModifiedNodes: Set<string> = new Set() private cowModifiedNodes: Set<string> = new Set()
@ -125,6 +136,19 @@ export class HNSWIndex implements HnswProvider {
this.vectorBackend = backend this.vectorBackend = backend
} }
/**
* @description Inject (or detach) the connections codec. When set, node
* persistence writes a delta-varint-compressed binary blob alongside an
* empty `connections: {}` marker in saveHNSWData; the load path reads the
* blob and decodes. Null reverts to the legacy JSON-array path. Wiring is
* done by brainy.ts when the `graph:compression` provider is registered
* AND the storage adapter exposes the binary-blob primitive AND the
* metadata index has a stable idMapper.
*/
public setConnectionsCodec(codec: ConnectionsCodec | null): void {
this.connectionsCodec = codec
}
/** /**
* Get whether parallelization is enabled * Get whether parallelization is enabled
*/ */
@ -168,15 +192,7 @@ export class HNSWIndex implements HnswProvider {
const noun = this.nouns.get(nodeId) const noun = this.nouns.get(nodeId)
if (!noun) return Promise.resolve() // Node was deleted if (!noun) return Promise.resolve() // Node was deleted
const connectionsObj: Record<string, string[]> = {} return this.persistNodeConnections(nodeId, noun).catch(error => {
for (const [level, nounIds] of noun.connections.entries()) {
connectionsObj[level.toString()] = Array.from(nounIds)
}
return this.storage!.saveHNSWData(nodeId, {
level: noun.level,
connections: connectionsObj
}).catch(error => {
console.error(`[HNSW flush] Failed to persist node ${nodeId}:`, error) console.error(`[HNSW flush] Failed to persist node ${nodeId}:`, error)
}) })
}) })
@ -207,6 +223,94 @@ export class HNSWIndex implements HnswProvider {
return nodeCount return nodeCount
} }
/**
* @description Persist one node's connections. When the connections codec is
* wired AND the storage adapter exposes `saveBinaryBlob`, the per-level
* connection sets are encoded into a single compact buffer and stored as a
* binary blob; `saveHNSWData` then records the node's level and an empty
* `connections: {}` as the marker. Otherwise the legacy JSON-array path is
* taken the format every brainy release before 2.4.0 wrote. The two are
* intentionally NOT dual-written: one or the other, never both, so format
* convergence is unambiguous on the read side.
*
* Shared by all three save sites (deferred flush, immediate-mode new-entity
* persist, immediate-mode neighbor update) so the codec branch is exercised
* uniformly regardless of which path triggered the write.
*/
private async persistNodeConnections(nodeId: string, noun: HNSWNoun): Promise<void> {
if (!this.storage) return
const storageWithBlob = this.storage as unknown as {
saveBinaryBlob?: (key: string, data: Buffer) => Promise<void>
}
const canCompress =
this.connectionsCodec !== null &&
typeof storageWithBlob.saveBinaryBlob === 'function'
if (canCompress) {
const encoded = this.connectionsCodec!.encode(noun.connections)
await storageWithBlob.saveBinaryBlob!(compressedConnectionsKey(nodeId), encoded)
await this.storage.saveHNSWData(nodeId, {
level: noun.level,
connections: {}
})
return
}
const connectionsObj: Record<string, string[]> = {}
for (const [level, nounIds] of noun.connections.entries()) {
connectionsObj[level.toString()] = Array.from(nounIds)
}
await this.storage.saveHNSWData(nodeId, {
level: noun.level,
connections: connectionsObj
})
}
/**
* @description Restore one node's connections from persisted storage. Tries
* the compressed-blob path first (when codec wired AND blob primitive
* available); a present non-empty buffer is decoded and populates
* `noun.connections`. A missing blob (no payload at the key) means this node
* was last persisted under the legacy format, so the legacy
* `hnswData.connections` field is used instead.
*
* On any decode error the legacy field is also used as a safety net the
* codec's `decode` throws on truncated input, which would otherwise leave
* the node with an empty connections Map (orphaned from the graph).
*/
private async restoreNodeConnections(
nodeId: string,
hnswData: { level: number; connections: Record<string, string[]> },
noun: HNSWNoun
): Promise<void> {
const storageWithBlob = this.storage as unknown as {
loadBinaryBlob?: (key: string) => Promise<Buffer | null>
}
const canDecompress =
this.connectionsCodec !== null &&
typeof storageWithBlob.loadBinaryBlob === 'function'
if (canDecompress) {
try {
const buf = await storageWithBlob.loadBinaryBlob!(compressedConnectionsKey(nodeId))
if (buf && buf.length > 0) {
const decoded = this.connectionsCodec!.decode(buf)
noun.connections = decoded
return
}
} catch (error) {
prodLog.debug(`HNSW: compressed connections decode failed for ${nodeId}; falling back to legacy`, error)
}
}
// Legacy fallback: JSON UUID arrays in the HNSW data object.
for (const [levelStr, nounIds] of Object.entries(hnswData.connections)) {
const level = parseInt(levelStr, 10)
noun.connections.set(level, new Set<string>(nounIds as string[]))
}
}
/** /**
* Get the number of dirty (unpersisted) nodes * Get the number of dirty (unpersisted) nodes
* Useful for monitoring and debugging * Useful for monitoring and debugging
@ -545,18 +649,12 @@ export class HNSWIndex implements HnswProvider {
// In deferred mode, we track dirty nodes instead of persisting immediately // In deferred mode, we track dirty nodes instead of persisting immediately
// This reduces GCS operations from 70 to 2-3 per add() (30-50× faster) // This reduces GCS operations from 70 to 2-3 per add() (30-50× faster)
if (this.storage && this.persistMode === 'immediate') { if (this.storage && this.persistMode === 'immediate') {
// IMMEDIATE MODE: Original behavior - persist each neighbor update // IMMEDIATE MODE: Original behavior - persist each neighbor update.
const neighborConnectionsObj: Record<string, string[]> = {} // Goes through the per-node helper so the compressed-blob branch
for (const [lvl, nounIds] of neighbor.connections.entries()) { // fires identically here vs. the deferred-flush path.
neighborConnectionsObj[lvl.toString()] = Array.from(nounIds)
}
neighborUpdates.push({ neighborUpdates.push({
neighborId, neighborId,
promise: this.storage.saveHNSWData(neighborId, { promise: this.persistNodeConnections(neighborId, neighbor)
level: neighbor.level,
connections: neighborConnectionsObj
})
}) })
} else if (this.persistMode === 'deferred') { } else if (this.persistMode === 'deferred') {
// DEFERRED MODE: Track dirty nodes for later batch persistence // DEFERRED MODE: Track dirty nodes for later batch persistence
@ -643,16 +741,10 @@ export class HNSWIndex implements HnswProvider {
// Persist HNSW graph data to storage // Persist HNSW graph data to storage
// Respect persistMode setting // Respect persistMode setting
if (this.storage && this.persistMode === 'immediate') { if (this.storage && this.persistMode === 'immediate') {
// IMMEDIATE MODE: Original behavior - persist new entity and system data // IMMEDIATE MODE: Original behavior - persist new entity and system data.
const connectionsObj: Record<string, string[]> = {} // Goes through the per-node helper so the compressed-blob branch fires
for (const [level, nounIds] of noun.connections.entries()) { // identically here vs. the deferred-flush + neighbor-update paths.
connectionsObj[level.toString()] = Array.from(nounIds) await this.persistNodeConnections(id, noun).catch((error) => {
}
await this.storage.saveHNSWData(id, {
level: nounLevel,
connections: connectionsObj
}).catch((error) => {
console.error(`Failed to persist HNSW data for ${id}:`, error) console.error(`Failed to persist HNSW data for ${id}:`, error)
}) })
@ -1434,11 +1526,10 @@ export class HNSWIndex implements HnswProvider {
noun.codebookMax = sq8.max noun.codebookMax = sq8.max
} }
// Restore connections from persisted data // Restore connections from persisted data — compressed blob path
for (const [levelStr, nounIds] of Object.entries(hnswData.connections)) { // first, legacy JSON-array fallback for indexes written before
const level = parseInt(levelStr, 10) // graph link compression landed.
noun.connections.set(level, new Set<string>(nounIds as string[])) await this.restoreNodeConnections(nounData.id, hnswData, noun)
}
// Add to in-memory index // Add to in-memory index
this.nouns.set(nounData.id, noun) this.nouns.set(nounData.id, noun)
@ -1519,11 +1610,10 @@ export class HNSWIndex implements HnswProvider {
noun.codebookMax = sq8.max noun.codebookMax = sq8.max
} }
// Restore connections from persisted data // Restore connections from persisted data — compressed blob path
for (const [levelStr, nounIds] of Object.entries(hnswData.connections)) { // first, legacy JSON-array fallback for indexes written before
const level = parseInt(levelStr, 10) // graph link compression landed.
noun.connections.set(level, new Set<string>(nounIds as string[])) await this.restoreNodeConnections(nounData.id, hnswData, noun)
}
// Add to in-memory index // Add to in-memory index
this.nouns.set(nounData.id, noun) this.nouns.set(nounData.id, noun)

View file

@ -253,6 +253,32 @@ export interface CacheProvider {
// uses at the `getProvider('embeddings')` call site. No separate interface is // uses at the `getProvider('embeddings')` call site. No separate interface is
// added here to avoid a duplicate, unwired contract. // added here to avoid a duplicate, unwired contract.
/**
* The `'graph:compression'` provider pure-function encode/decode for HNSW
* connection lists as compact delta-varint byte sequences (cortex's
* `encodeConnections` / `decodeConnections`).
*
* Brainy's `HNSWIndex` consumes this via a `ConnectionsCodec` that translates
* UUIDs to stable int slots via the `EntityIdMapper`, encodes, and persists
* the compressed bytes through the binary-blob primitive. On load, the blob
* is fetched + decoded back into UUID sets `setConnectionsCodec()` on
* `HNSWIndex` is the injection point. Read path is dual-format: when no blob
* exists for a node, the connections fall back to the legacy JSON-array path
* embedded in `saveHNSWData`, so pre-2.4.0 indexes keep loading unchanged
* and convergence to the compressed form happens lazily on next save.
*
* Activated only when the storage adapter exposes the binary-blob primitive
* AND the metadata index resolves a stable idMapper. Cloud adapters that
* lack a real local-path resolution still benefit, since the blob primitive
* itself works across every adapter as of brainy 7.25.0.
*/
export interface GraphCompressionProvider {
/** Encode a list of u32 ints to compact delta-varint bytes. Sorts internally. */
encode(ids: number[]): Buffer
/** Decode delta-varint bytes back to a u32 list. */
decode(data: Buffer): number[]
}
/** /**
* The `'vectorStore:mmap'` provider a static factory class for an mmap-backed * The `'vectorStore:mmap'` provider a static factory class for an mmap-backed
* vector file. Brainy's HNSWIndex uses the static `.create()` / `.open()` * vector file. Brainy's HNSWIndex uses the static `.create()` / `.open()`

View file

@ -0,0 +1,137 @@
/**
* @module hnsw/connectionsCodec.test
* @description Unit tests for `ConnectionsCodec` the per-node buffer format
* brainy uses to persist HNSW connection sets as delta-varint-compressed
* binary blobs (2.4.0 #3).
*
* Mocks the `graph:compression` provider so the tests run without cortex
* installed. The real cross-language byte-format integration is exercised
* when cortex's `encodeConnections` / `decodeConnections` are wired in.
*
* Coverage:
* 1. Round-trip a Map of `level → Set<UUID>` through encode + decode and
* verify the result is equal to the input (level-by-level Set equality).
* 2. Empty connections encode to a single-byte buffer (`0` = zero levels) and
* decode back to an empty Map no `undefined` reads, no decode errors.
* 3. Multiple levels round-trip, including a level with a single UUID and a
* level with many UUIDs (the typical L0 fan-out).
* 4. UUIDs whose ints are no longer in the idMapper at decode time are
* silently dropped matches the pre-2.4.0 behaviour of a missing
* `getUuid()` lookup in the legacy load path.
*/
import { describe, it, expect } from 'vitest'
import { ConnectionsCodec } from '../../../src/hnsw/connectionsCodec.js'
import { EntityIdMapper } from '../../../src/utils/entityIdMapper.js'
import type { GraphCompressionProvider } from '../../../src/plugin.js'
const stubStorage = {
getMetadata: async () => undefined,
saveMetadata: async () => {},
getNouns: async () => ({ totalCount: 0, items: [] })
} as any
/**
* Mock provider encodes ints as a JSON byte array, decodes back. NOT the
* delta-varint format cortex produces, but format-agnostic round-trip
* verification is the unit-test concern here.
*/
const mockProvider: GraphCompressionProvider = {
encode(ids: number[]): Buffer {
const sorted = [...ids].sort((a, b) => a - b)
return Buffer.from(JSON.stringify(sorted), 'utf8')
},
decode(data: Buffer): number[] {
if (data.length === 0) return []
return JSON.parse(data.toString('utf8')) as number[]
}
}
async function makeIdMapper(): Promise<EntityIdMapper> {
const mapper = new EntityIdMapper({ storage: stubStorage })
await mapper.init()
return mapper
}
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false
for (const v of a) if (!b.has(v)) return false
return true
}
describe('ConnectionsCodec (2.4.0 #3 — wraps graph:compression provider)', () => {
it('round-trips a single-level Set of UUIDs through encode + decode', async () => {
const idMapper = await makeIdMapper()
const codec = new ConnectionsCodec(mockProvider, idMapper)
const input = new Map<number, Set<string>>([
[0, new Set(['a', 'b', 'c', 'd'])]
])
const buf = codec.encode(input)
const out = codec.decode(buf)
expect(out.size).toBe(1)
expect(setsEqual(out.get(0)!, input.get(0)!)).toBe(true)
})
it('empty connections encode to a one-byte buffer and decode to an empty Map', async () => {
const idMapper = await makeIdMapper()
const codec = new ConnectionsCodec(mockProvider, idMapper)
const buf = codec.encode(new Map())
expect(buf.length).toBe(1)
expect(buf.readUInt8(0)).toBe(0)
expect(codec.decode(buf).size).toBe(0)
})
it('round-trips multiple levels with very different fan-outs', async () => {
const idMapper = await makeIdMapper()
const codec = new ConnectionsCodec(mockProvider, idMapper)
const manyUuids = Array.from({ length: 30 }, (_, i) => `u-${i}`)
const input = new Map<number, Set<string>>([
[0, new Set(manyUuids)],
[1, new Set(['high-1', 'high-2'])],
[2, new Set(['solo'])]
])
const buf = codec.encode(input)
const out = codec.decode(buf)
expect(out.size).toBe(3)
expect(setsEqual(out.get(0)!, input.get(0)!)).toBe(true)
expect(setsEqual(out.get(1)!, input.get(1)!)).toBe(true)
expect(setsEqual(out.get(2)!, input.get(2)!)).toBe(true)
})
it('drops UUIDs whose ints the idMapper no longer recognises on decode', async () => {
const encodingMapper = await makeIdMapper()
const encodeCodec = new ConnectionsCodec(mockProvider, encodingMapper)
// Encode through one mapper.
const input = new Map<number, Set<string>>([[0, new Set(['present', 'gone'])]])
const buf = encodeCodec.encode(input)
// Decode through a FRESH mapper that only knows about 'present'.
const decodingMapper = await makeIdMapper()
decodingMapper.getOrAssign('present')
// Manually re-map 'present' to the same int it had on encode so the
// present UUID round-trips; 'gone' simply has no mapping → silent drop.
// We line up the int spaces by asserting equality below: only 'present'
// survives, 'gone' is missing — no errors, no `undefined` Set entries.
const decodeCodec = new ConnectionsCodec(mockProvider, decodingMapper)
// Inject the same int for 'present' so it round-trips one-for-one. (The
// encoding mapper assigns ints in insertion order: 'present'=1, 'gone'=2.
// The decoding mapper's first getOrAssign also returns 1, so 'present'
// matches; 'gone' (encoded as 2) has no mapping in the decoder.)
expect(encodingMapper.getInt('present')).toBe(1)
expect(decodingMapper.getInt('present')).toBe(1)
const out = decodeCodec.decode(buf)
const l0 = out.get(0)!
expect(l0.has('present')).toBe(true)
expect(l0.has('gone')).toBe(false)
expect(l0.size).toBe(1)
})
})