feat: unified column store for filtering + sorting at billion scale
Adds a per-field sorted column store (Lucene doc values + roaring bitmap
architecture) that replaces the MetadataIndex sparse index internals for
both filtering and sorting. One system for all field types with exact
precision — no bucketing, no per-entity storage reads.
Column store: binary .cidx segment format, in-memory tail buffers,
LSM-style compaction, k-way merge sort, multi-value support (__words__).
All queries (filter, range, sort, filtered sort) route through the column
store when data is available, falling back to sparse index otherwise.
Key unlocks:
- find({ orderBy: 'createdAt' }) works WITHOUT a filter (previously threw)
- find({ orderBy: 'metadata.price' }) works for custom numeric fields
- Exact timestamp precision (no 1-minute bucketing)
- O(K log S) sort independent of total entity count
New files: src/indexes/columnStore/ (types, format, tail buffer, cursor,
manifest, coordinator — ~700 lines). 101 new unit tests covering binary
format round-trips, CRC validation, sort, filter, range, deletion,
multi-segment merge, persistence, and multi-value (words) fields.
Deleted: metadataIndex-automatic-bucketing.test.ts (bucketing behavior
eliminated by exact-precision column store). Sparse index write path
removed from addToIndex/removeFromIndex. Sparse index legacy code still
present as dead code pending cleanup in next commit.
This commit is contained in:
parent
634ddfb2bb
commit
46583f2d9b
16 changed files with 3846 additions and 521 deletions
548
src/indexes/columnStore/ColumnSegmentFormat.ts
Normal file
548
src/indexes/columnStore/ColumnSegmentFormat.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
/**
|
||||
* @module columnStore/ColumnSegmentFormat
|
||||
* @description Binary reader/writer for `.cidx` column segment files.
|
||||
*
|
||||
* Segment layout (little-endian throughout):
|
||||
* ```
|
||||
* ┌─────────────────────────────────────────────┐
|
||||
* │ HEADER (64 bytes) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ VALUES (count × 8 bytes for numeric, │
|
||||
* │ variable for strings) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ ENTITY IDS (count × 4 bytes, u32 LE) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ TOMBSTONES (serialized roaring bitmap) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ FOOTER (16 bytes) │
|
||||
* └─────────────────────────────────────────────┘
|
||||
* ```
|
||||
*
|
||||
* Both TypeScript and Rust read/write this exact byte layout.
|
||||
* Cross-language format tests validate compatibility.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import {
|
||||
CIDX_MAGIC,
|
||||
CIDX_VERSION,
|
||||
HEADER_SIZE,
|
||||
FOOTER_SIZE,
|
||||
MAX_FIELD_NAME_LENGTH,
|
||||
ValueType,
|
||||
type SegmentHeader,
|
||||
type SegmentFooter
|
||||
} from './types.js'
|
||||
import { RoaringBitmap32 } from '../../utils/roaring/index.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRC32 — use a simple table-based implementation (no external dep)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRC32_TABLE = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i
|
||||
for (let j = 0; j < 8; j++) {
|
||||
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
|
||||
}
|
||||
CRC32_TABLE[i] = c
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute CRC32 of a buffer.
|
||||
*
|
||||
* @param data - Input bytes
|
||||
* @returns CRC32 checksum as unsigned 32-bit integer
|
||||
*/
|
||||
export function crc32(data: Uint8Array): number {
|
||||
let crc = 0xFFFFFFFF
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >>> 8)
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header read / write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write a 64-byte segment header into a buffer.
|
||||
*
|
||||
* @param header - Parsed header fields
|
||||
* @returns 64-byte Buffer
|
||||
*/
|
||||
export function writeHeader(header: SegmentHeader): Buffer {
|
||||
const buf = Buffer.alloc(HEADER_SIZE)
|
||||
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||||
|
||||
// [0..4) magic
|
||||
view.setUint32(0, header.magic, true)
|
||||
// [4..6) version
|
||||
view.setUint16(4, header.version, true)
|
||||
|
||||
// [6..8) fieldNameLength
|
||||
const nameBytes = Buffer.from(header.fieldName, 'utf-8')
|
||||
const nameLen = Math.min(nameBytes.length, MAX_FIELD_NAME_LENGTH)
|
||||
view.setUint16(6, nameLen, true)
|
||||
|
||||
// [8..40) fieldName (zero-padded)
|
||||
nameBytes.copy(buf, 8, 0, nameLen)
|
||||
|
||||
// [40..41) valueType
|
||||
buf[40] = header.valueType
|
||||
// [41..42) level
|
||||
buf[41] = header.level
|
||||
// [42..43) codec
|
||||
buf[42] = header.codec
|
||||
// [43..44) flags
|
||||
buf[43] = header.flags
|
||||
|
||||
// [44..48) reserved
|
||||
// [48..56) count as u64
|
||||
view.setBigUint64(48, BigInt(header.count), true)
|
||||
// [56..64) reserved
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 64-byte segment header from a buffer.
|
||||
*
|
||||
* @param buf - At least 64 bytes
|
||||
* @returns Parsed SegmentHeader
|
||||
* @throws If magic or version don't match
|
||||
*/
|
||||
export function readHeader(buf: Buffer | Uint8Array): SegmentHeader {
|
||||
if (buf.length < HEADER_SIZE) {
|
||||
throw new Error(`Buffer too small for header: ${buf.length} < ${HEADER_SIZE}`)
|
||||
}
|
||||
|
||||
const view = new DataView(
|
||||
buf instanceof Buffer ? buf.buffer : buf.buffer,
|
||||
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
|
||||
HEADER_SIZE
|
||||
)
|
||||
|
||||
const magic = view.getUint32(0, true)
|
||||
if (magic !== CIDX_MAGIC) {
|
||||
throw new Error(`Invalid segment magic: 0x${magic.toString(16)} (expected 0x${CIDX_MAGIC.toString(16)})`)
|
||||
}
|
||||
|
||||
const version = view.getUint16(4, true)
|
||||
if (version !== CIDX_VERSION) {
|
||||
throw new Error(`Unsupported segment version: ${version} (expected ${CIDX_VERSION})`)
|
||||
}
|
||||
|
||||
const fieldNameLength = view.getUint16(6, true)
|
||||
const fieldName = Buffer.from(buf.slice(8, 8 + fieldNameLength)).toString('utf-8')
|
||||
|
||||
return {
|
||||
magic,
|
||||
version,
|
||||
fieldName,
|
||||
fieldNameLength,
|
||||
valueType: buf[40] as ValueType,
|
||||
level: buf[41],
|
||||
codec: buf[42],
|
||||
flags: buf[43],
|
||||
count: Number(view.getBigUint64(48, true))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Footer read / write
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write a 16-byte footer.
|
||||
*
|
||||
* @param tombstoneLength - Byte length of the serialized tombstone bitmap
|
||||
* @param checksum - CRC32 of header + values + entityIds + tombstones
|
||||
* @returns 16-byte Buffer
|
||||
*/
|
||||
export function writeFooter(tombstoneLength: number, checksum: number): Buffer {
|
||||
const buf = Buffer.alloc(FOOTER_SIZE)
|
||||
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||||
view.setBigUint64(0, BigInt(tombstoneLength), true)
|
||||
view.setUint32(8, checksum, true)
|
||||
// [12..16) pad
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a 16-byte footer.
|
||||
*
|
||||
* @param buf - Exactly 16 bytes
|
||||
* @returns Parsed footer
|
||||
*/
|
||||
export function readFooter(buf: Buffer | Uint8Array): SegmentFooter {
|
||||
if (buf.length < FOOTER_SIZE) {
|
||||
throw new Error(`Buffer too small for footer: ${buf.length} < ${FOOTER_SIZE}`)
|
||||
}
|
||||
const view = new DataView(
|
||||
buf instanceof Buffer ? buf.buffer : buf.buffer,
|
||||
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
|
||||
FOOTER_SIZE
|
||||
)
|
||||
return {
|
||||
tombstoneLength: Number(view.getBigUint64(0, true)),
|
||||
crc32: view.getUint32(8, true)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value column encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encode a sorted array of numeric values into a packed i64 LE buffer.
|
||||
*
|
||||
* @param values - Sorted numbers (integers or timestamps)
|
||||
* @returns Packed buffer (values.length × 8 bytes)
|
||||
*/
|
||||
export function encodeNumericValues(values: number[]): Buffer {
|
||||
const buf = Buffer.alloc(values.length * 8)
|
||||
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
view.setBigInt64(i * 8, BigInt(Math.round(values[i])), true)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a packed i64 LE buffer into a number array.
|
||||
*
|
||||
* @param buf - Packed buffer
|
||||
* @param count - Number of values
|
||||
* @returns Array of numbers
|
||||
*/
|
||||
export function decodeNumericValues(buf: Buffer | Uint8Array, count: number): number[] {
|
||||
const view = new DataView(
|
||||
buf instanceof Buffer ? buf.buffer : buf.buffer,
|
||||
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
|
||||
count * 8
|
||||
)
|
||||
const result = new Array<number>(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = Number(view.getBigInt64(i * 8, true))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a sorted array of float values into a packed f64 LE buffer.
|
||||
*
|
||||
* @param values - Sorted floats
|
||||
* @returns Packed buffer (values.length × 8 bytes)
|
||||
*/
|
||||
export function encodeFloatValues(values: number[]): Buffer {
|
||||
const buf = Buffer.alloc(values.length * 8)
|
||||
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
view.setFloat64(i * 8, values[i], true)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a packed f64 LE buffer into a number array.
|
||||
*
|
||||
* @param buf - Packed buffer
|
||||
* @param count - Number of values
|
||||
* @returns Array of numbers
|
||||
*/
|
||||
export function decodeFloatValues(buf: Buffer | Uint8Array, count: number): number[] {
|
||||
const view = new DataView(
|
||||
buf instanceof Buffer ? buf.buffer : buf.buffer,
|
||||
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
|
||||
count * 8
|
||||
)
|
||||
const result = new Array<number>(count)
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = view.getFloat64(i * 8, true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode sorted string values as length-prefixed UTF-8.
|
||||
*
|
||||
* Format per string: u32 LE length + UTF-8 bytes (no padding, no null terminator).
|
||||
*
|
||||
* @param values - Sorted strings
|
||||
* @returns Packed buffer
|
||||
*/
|
||||
export function encodeStringValues(values: string[]): Buffer {
|
||||
// First pass: calculate total size
|
||||
const encoded = values.map(v => Buffer.from(v, 'utf-8'))
|
||||
const totalSize = encoded.reduce((sum, b) => sum + 4 + b.length, 0)
|
||||
|
||||
const buf = Buffer.alloc(totalSize)
|
||||
let offset = 0
|
||||
for (const bytes of encoded) {
|
||||
buf.writeUInt32LE(bytes.length, offset)
|
||||
offset += 4
|
||||
bytes.copy(buf, offset)
|
||||
offset += bytes.length
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode length-prefixed UTF-8 strings.
|
||||
*
|
||||
* @param buf - Packed buffer
|
||||
* @param count - Number of strings
|
||||
* @returns Array of strings
|
||||
*/
|
||||
export function decodeStringValues(buf: Buffer | Uint8Array, count: number): string[] {
|
||||
const result = new Array<string>(count)
|
||||
let offset = 0
|
||||
const b = buf instanceof Buffer ? buf : Buffer.from(buf)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const len = b.readUInt32LE(offset)
|
||||
offset += 4
|
||||
result[i] = b.toString('utf-8', offset, offset + len)
|
||||
offset += len
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode entity int IDs as packed u32 LE.
|
||||
*
|
||||
* @param ids - Entity integer IDs (parallel to value column)
|
||||
* @returns Packed buffer (ids.length × 4 bytes)
|
||||
*/
|
||||
export function encodeEntityIds(ids: Uint32Array | number[]): Buffer {
|
||||
const arr = ids instanceof Uint32Array ? ids : new Uint32Array(ids)
|
||||
return Buffer.from(arr.buffer, arr.byteOffset, arr.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode packed u32 LE entity IDs.
|
||||
*
|
||||
* @param buf - Packed buffer
|
||||
* @param count - Number of IDs
|
||||
* @returns Uint32Array
|
||||
*/
|
||||
export function decodeEntityIds(buf: Buffer | Uint8Array, count: number): Uint32Array {
|
||||
const result = new Uint32Array(count)
|
||||
const view = new DataView(
|
||||
buf instanceof Buffer ? buf.buffer : buf.buffer,
|
||||
buf instanceof Buffer ? buf.byteOffset : buf.byteOffset,
|
||||
count * 4
|
||||
)
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = view.getUint32(i * 4, true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full segment write / read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Encode values based on ValueType.
|
||||
*
|
||||
* @param values - Sorted values
|
||||
* @param valueType - How to encode
|
||||
* @returns Packed buffer
|
||||
*/
|
||||
export function encodeValues(values: (number | string | boolean)[], valueType: ValueType): Buffer {
|
||||
switch (valueType) {
|
||||
case ValueType.Number:
|
||||
case ValueType.Boolean:
|
||||
return encodeNumericValues(values as number[])
|
||||
case ValueType.Float:
|
||||
return encodeFloatValues(values as number[])
|
||||
case ValueType.String:
|
||||
return encodeStringValues(values as string[])
|
||||
default:
|
||||
throw new Error(`Unknown ValueType: ${valueType}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode values based on ValueType.
|
||||
*
|
||||
* @param buf - Packed value buffer
|
||||
* @param count - Number of values
|
||||
* @param valueType - How to decode
|
||||
* @returns Array of values
|
||||
*/
|
||||
export function decodeValues(buf: Buffer | Uint8Array, count: number, valueType: ValueType): (number | string)[] {
|
||||
switch (valueType) {
|
||||
case ValueType.Number:
|
||||
case ValueType.Boolean:
|
||||
return decodeNumericValues(buf, count)
|
||||
case ValueType.Float:
|
||||
return decodeFloatValues(buf, count)
|
||||
case ValueType.String:
|
||||
return decodeStringValues(buf, count)
|
||||
default:
|
||||
throw new Error(`Unknown ValueType: ${valueType}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the byte size of the values column for a given count and type.
|
||||
* For strings this is not knowable without the data, so returns -1.
|
||||
*
|
||||
* @param count - Number of entries
|
||||
* @param valueType - Value encoding type
|
||||
* @returns Byte size, or -1 for variable-length (strings)
|
||||
*/
|
||||
export function valuesColumnSize(count: number, valueType: ValueType): number {
|
||||
switch (valueType) {
|
||||
case ValueType.Number:
|
||||
case ValueType.Boolean:
|
||||
case ValueType.Float:
|
||||
return count * 8
|
||||
case ValueType.String:
|
||||
return -1 // variable length
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a complete `.cidx` segment file to a Buffer.
|
||||
*
|
||||
* @param header - Segment header (magic and version are set automatically)
|
||||
* @param values - Sorted value array
|
||||
* @param entityIds - Parallel entity int ID array
|
||||
* @param tombstones - Optional tombstone bitmap (positions that are logically deleted)
|
||||
* @returns Complete segment as a Buffer
|
||||
*/
|
||||
export function writeSegmentToBuffer(
|
||||
header: Omit<SegmentHeader, 'magic' | 'version'>,
|
||||
values: (number | string | boolean)[],
|
||||
entityIds: Uint32Array | number[],
|
||||
tombstones?: RoaringBitmap32
|
||||
): Buffer {
|
||||
if (values.length !== (entityIds instanceof Uint32Array ? entityIds.length : entityIds.length)) {
|
||||
throw new Error(`Values (${values.length}) and entityIds (${entityIds instanceof Uint32Array ? entityIds.length : entityIds.length}) must have the same length`)
|
||||
}
|
||||
|
||||
const fullHeader: SegmentHeader = {
|
||||
...header,
|
||||
magic: CIDX_MAGIC,
|
||||
version: CIDX_VERSION,
|
||||
count: values.length
|
||||
}
|
||||
|
||||
// Encode sections
|
||||
const headerBuf = writeHeader(fullHeader)
|
||||
const valuesBuf = encodeValues(values, header.valueType)
|
||||
const idsBuf = encodeEntityIds(entityIds)
|
||||
|
||||
// Serialize tombstones
|
||||
let tombstoneBuf: Buffer
|
||||
if (tombstones && tombstones.size > 0) {
|
||||
const serialized = tombstones.serialize(true) // portable serialization
|
||||
tombstoneBuf = Buffer.from(serialized)
|
||||
} else {
|
||||
tombstoneBuf = Buffer.alloc(0)
|
||||
}
|
||||
|
||||
// CRC32 covers header + values + entityIds + tombstones
|
||||
const crcInput = Buffer.concat([headerBuf, valuesBuf, idsBuf, tombstoneBuf])
|
||||
const checksum = crc32(crcInput)
|
||||
|
||||
const footerBuf = writeFooter(tombstoneBuf.length, checksum)
|
||||
|
||||
return Buffer.concat([headerBuf, valuesBuf, idsBuf, tombstoneBuf, footerBuf])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed segment data from a `.cidx` file.
|
||||
*/
|
||||
export interface ParsedSegment {
|
||||
header: SegmentHeader
|
||||
values: (number | string)[]
|
||||
entityIds: Uint32Array
|
||||
tombstones: RoaringBitmap32
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a complete `.cidx` segment from a Buffer.
|
||||
*
|
||||
* @param buf - Complete segment buffer
|
||||
* @param validateCrc - Whether to validate the CRC32 checksum (default: true)
|
||||
* @returns Parsed segment data
|
||||
* @throws If magic/version mismatch, buffer too small, or CRC mismatch
|
||||
*/
|
||||
export function readSegmentFromBuffer(buf: Buffer | Uint8Array, validateCrc = true): ParsedSegment {
|
||||
if (buf.length < HEADER_SIZE + FOOTER_SIZE) {
|
||||
throw new Error(`Buffer too small for segment: ${buf.length} < ${HEADER_SIZE + FOOTER_SIZE}`)
|
||||
}
|
||||
|
||||
// 1. Parse header
|
||||
const header = readHeader(buf)
|
||||
|
||||
// 2. Parse footer (last 16 bytes)
|
||||
const footerStart = buf.length - FOOTER_SIZE
|
||||
const footer = readFooter(buf.slice(footerStart))
|
||||
|
||||
// 3. Calculate section offsets
|
||||
const valuesStart = HEADER_SIZE
|
||||
const idsSize = header.count * 4
|
||||
const tombstoneSize = footer.tombstoneLength
|
||||
|
||||
// For fixed-size values, we know exact offsets
|
||||
const fixedValuesSize = valuesColumnSize(header.count, header.valueType)
|
||||
|
||||
let valuesEnd: number
|
||||
if (fixedValuesSize >= 0) {
|
||||
valuesEnd = valuesStart + fixedValuesSize
|
||||
} else {
|
||||
// String values: entityIds start = footerStart - tombstoneSize - idsSize
|
||||
valuesEnd = footerStart - tombstoneSize - idsSize
|
||||
}
|
||||
|
||||
const idsStart = valuesEnd
|
||||
const idsEnd = idsStart + idsSize
|
||||
const tombstoneStart = idsEnd
|
||||
const tombstoneEnd = tombstoneStart + tombstoneSize
|
||||
|
||||
// 4. Validate CRC
|
||||
if (validateCrc) {
|
||||
const crcInput = buf.slice(0, tombstoneEnd)
|
||||
const computed = crc32(crcInput instanceof Buffer ? crcInput : Buffer.from(crcInput))
|
||||
if (computed !== footer.crc32) {
|
||||
throw new Error(`CRC32 mismatch: computed 0x${computed.toString(16)} != stored 0x${footer.crc32.toString(16)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Decode values
|
||||
const valuesBuf = buf.slice(valuesStart, valuesEnd)
|
||||
const values = decodeValues(
|
||||
valuesBuf instanceof Buffer ? valuesBuf : Buffer.from(valuesBuf),
|
||||
header.count,
|
||||
header.valueType
|
||||
)
|
||||
|
||||
// 6. Decode entity IDs
|
||||
const idsBuf = buf.slice(idsStart, idsEnd)
|
||||
const entityIds = decodeEntityIds(
|
||||
idsBuf instanceof Buffer ? idsBuf : Buffer.from(idsBuf),
|
||||
header.count
|
||||
)
|
||||
|
||||
// 7. Decode tombstones
|
||||
let tombstones: RoaringBitmap32
|
||||
if (tombstoneSize > 0) {
|
||||
const tombstoneBuf = buf.slice(tombstoneStart, tombstoneEnd)
|
||||
tombstones = RoaringBitmap32.deserialize(
|
||||
tombstoneBuf instanceof Buffer ? tombstoneBuf : Buffer.from(tombstoneBuf),
|
||||
true // portable format
|
||||
)
|
||||
} else {
|
||||
tombstones = new RoaringBitmap32()
|
||||
}
|
||||
|
||||
return { header, values, entityIds, tombstones }
|
||||
}
|
||||
Reference in a new issue