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.
548 lines
17 KiB
TypeScript
548 lines
17 KiB
TypeScript
/**
|
||
* @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 }
|
||
}
|