@soulcraft/cor 3.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +328 -0
- package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
- package/dist/aggregation/NativeAggregationEngine.js +186 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +236 -0
- package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
- package/dist/graph/GraphAccelerationAdapter.js +326 -0
- package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
- package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
- package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
- package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
- package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +29 -0
- package/dist/legacyLayoutGuard.d.ts +93 -0
- package/dist/legacyLayoutGuard.js +237 -0
- package/dist/license/constants.d.ts +30 -0
- package/dist/license/constants.js +30 -0
- package/dist/license/onlineValidator.d.ts +43 -0
- package/dist/license/onlineValidator.js +225 -0
- package/dist/license/types.d.ts +39 -0
- package/dist/license/types.js +5 -0
- package/dist/license.d.ts +64 -0
- package/dist/license.js +165 -0
- package/dist/migration/MigrationCoordinator.d.ts +171 -0
- package/dist/migration/MigrationCoordinator.js +249 -0
- package/dist/migration/indexEpochGuard.d.ts +163 -0
- package/dist/migration/indexEpochGuard.js +202 -0
- package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
- package/dist/native/NativeEmbeddingEngine.js +318 -0
- package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
- package/dist/native/NativeRoaringBitmap32.js +221 -0
- package/dist/native/ffi.d.ts +20 -0
- package/dist/native/ffi.js +48 -0
- package/dist/native/idMapperTestSupport.d.ts +67 -0
- package/dist/native/idMapperTestSupport.js +112 -0
- package/dist/native/index.d.ts +49 -0
- package/dist/native/index.js +87 -0
- package/dist/native/napi.d.ts +21 -0
- package/dist/native/napi.js +88 -0
- package/dist/native/types.d.ts +1298 -0
- package/dist/native/types.js +16 -0
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +388 -0
- package/dist/providerContracts.d.ts +277 -0
- package/dist/providerContracts.js +38 -0
- package/dist/resource/OsMemoryProbe.d.ts +175 -0
- package/dist/resource/OsMemoryProbe.js +206 -0
- package/dist/resource/ResourceManager.d.ts +491 -0
- package/dist/resource/ResourceManager.js +960 -0
- package/dist/utils/ColumnManifest.d.ts +97 -0
- package/dist/utils/ColumnManifest.js +129 -0
- package/dist/utils/NativeColumnStore.d.ts +284 -0
- package/dist/utils/NativeColumnStore.js +685 -0
- package/dist/utils/NativeMetadataIndex.d.ts +882 -0
- package/dist/utils/NativeMetadataIndex.js +2631 -0
- package/dist/utils/NativeUnifiedCache.d.ts +87 -0
- package/dist/utils/NativeUnifiedCache.js +273 -0
- package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
- package/dist/utils/binaryIdMapperFactory.js +94 -0
- package/dist/utils/collation.d.ts +30 -0
- package/dist/utils/collation.js +40 -0
- package/dist/utils/columnStoreTypes.d.ts +161 -0
- package/dist/utils/columnStoreTypes.js +29 -0
- package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
- package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
- package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
- package/dist/utils/nativeEntityIdMapper.js +170 -0
- package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
- package/docs/ADR-003-semantic-time-travel.md +100 -0
- package/docs/aggregation.md +251 -0
- package/docs/comparison.md +162 -0
- package/docs/deployment-limits.md +87 -0
- package/docs/diskann.md +184 -0
- package/docs/migration-3.0.md +396 -0
- package/docs/performance-budget.md +274 -0
- package/docs/performance.md +117 -0
- package/docs/scaling.md +439 -0
- package/docs/snapshot-safety.md +246 -0
- package/docs/u64-id-space.md +214 -0
- package/docs/verification-report.md +670 -0
- package/native/brainy-native.node +0 -0
- package/native/index.d.ts +3916 -0
- package/package.json +92 -0
- package/scripts/migrate-cortex-2x-to-3x.mjs +970 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/NativeColumnStore
|
|
3
|
+
* @description Rust-accelerated implementation of brainy's `ColumnStoreProvider`.
|
|
4
|
+
*
|
|
5
|
+
* Brainy's unified column store keeps per-field sorted columns for filtering,
|
|
6
|
+
* range queries, and `orderBy` sorting at billion-entity scale. The TypeScript
|
|
7
|
+
* baseline ships in `@soulcraft/brainy`; cor registers this native version,
|
|
8
|
+
* which delegates every value-level operation to the Rust `NativeColumnStore`
|
|
9
|
+
* (`native/src/column_store`). The Rust engine owns the sorted-segment k-way
|
|
10
|
+
* merge, roaring-bitmap filters, and zero-copy mmap reads; this module owns the
|
|
11
|
+
* async storage bridge (segment persistence, manifests, deleted bitmaps) and
|
|
12
|
+
* the value typing/coercion that mirrors brainy exactly.
|
|
13
|
+
*
|
|
14
|
+
* Why cor needs this: cor's `MetadataIndexManager` does its own native
|
|
15
|
+
* metadata filtering, but brainy's *unfiltered* `find({ orderBy })` path calls
|
|
16
|
+
* `metadataIndex.columnStore.sortTopK(...)` directly. Without a `.columnStore`
|
|
17
|
+
* on cor's metadata index, that path throws. So the column store's real jobs
|
|
18
|
+
* here are POPULATE (as entities are indexed), PERSIST (segments + manifests),
|
|
19
|
+
* and SORT (`sortTopK`). `filter`/`rangeQuery`/`getFilterValues` are implemented
|
|
20
|
+
* for interface completeness and parity with the TS baseline.
|
|
21
|
+
*
|
|
22
|
+
* Storage is billion-scale and mmap-friendly: each flushed segment is written as
|
|
23
|
+
* a raw binary blob (NOT base64) via the storage adapter's `saveBinaryBlob`, and
|
|
24
|
+
* loaded zero-copy via `getBinaryBlobPath` + `loadSegmentFromPath` when a local
|
|
25
|
+
* path exists, falling back to `loadBinaryBlob` + `loadSegmentFromBuffer` for
|
|
26
|
+
* remote adapters with no local path. Manifests are small JSON files.
|
|
27
|
+
*
|
|
28
|
+
* Design decision: value typing/coercion is replicated from brainy's
|
|
29
|
+
* `ColumnStore.inferValueType`/`normalizeValue` so cor produces identical
|
|
30
|
+
* `.cidx` segment value types and ordering. See ADR-001 for the native string
|
|
31
|
+
* collation contract (code-point order, matching `compareCodePoints`).
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync } from 'node:fs';
|
|
34
|
+
import { prodLog } from '@soulcraft/brainy/internals';
|
|
35
|
+
import { ValueType } from './columnStoreTypes.js';
|
|
36
|
+
import { ColumnManifest } from './ColumnManifest.js';
|
|
37
|
+
import { loadNativeModule } from '../native/index.js';
|
|
38
|
+
import { RoaringBitmap32 } from '../native/NativeRoaringBitmap32.js';
|
|
39
|
+
/**
|
|
40
|
+
* @description Narrow check that a storage adapter supports the binary-blob
|
|
41
|
+
* primitives required for mmap segment persistence.
|
|
42
|
+
* @param storage - The storage adapter brainy handed to the metadata index.
|
|
43
|
+
* @returns True if all four binary-blob methods are present.
|
|
44
|
+
*/
|
|
45
|
+
function isBinaryBlobStorage(storage) {
|
|
46
|
+
const s = storage;
|
|
47
|
+
return (typeof s?.saveBinaryBlob === 'function' &&
|
|
48
|
+
typeof s?.loadBinaryBlob === 'function' &&
|
|
49
|
+
typeof s?.deleteBinaryBlob === 'function' &&
|
|
50
|
+
typeof s?.getBinaryBlobPath === 'function');
|
|
51
|
+
}
|
|
52
|
+
/** Base storage path for all column-index data, matching brainy's TS baseline. */
|
|
53
|
+
const BASE_PATH = '_column_index';
|
|
54
|
+
/**
|
|
55
|
+
* @description Build the binary-blob storage key for a flushed segment. The key
|
|
56
|
+
* intentionally carries NO `.cidx`/`.bin` suffix — the adapter appends its own
|
|
57
|
+
* (`.bin`). The same convention is used on load, so persistence round-trips.
|
|
58
|
+
* @param field - Field name the segment belongs to.
|
|
59
|
+
* @param level - LSM compaction level (always 0 for fresh flushes here).
|
|
60
|
+
* @param segId - Segment id, unique within the field.
|
|
61
|
+
* @returns Key like `_column_index/createdAt/L0-000001`.
|
|
62
|
+
*/
|
|
63
|
+
function segmentBlobKey(field, level, segId) {
|
|
64
|
+
return `${BASE_PATH}/${field}/L${level}-${String(segId).padStart(6, '0')}`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* @description Build the binary-blob storage key for a field's persisted
|
|
68
|
+
* deleted bitmap. Suffix-free for the same reason as {@link segmentBlobKey}.
|
|
69
|
+
* @param field - Field name.
|
|
70
|
+
* @returns Key like `_column_index/createdAt/DELETED`.
|
|
71
|
+
*/
|
|
72
|
+
function deletedBlobKey(field) {
|
|
73
|
+
return `${BASE_PATH}/${field}/DELETED`;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* NativeColumnStore — Rust-accelerated `ColumnStoreProvider`.
|
|
77
|
+
*
|
|
78
|
+
* Holds one native `NativeColumnStore` instance plus the TS-side state the Rust
|
|
79
|
+
* engine does not persist: per-field manifests, value types, deleted bitmaps,
|
|
80
|
+
* and the storage + id mapper handed in at `init`.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* const store = new NativeColumnStore()
|
|
84
|
+
* await store.init(storage, idMapper)
|
|
85
|
+
* store.addEntity(42, { createdAt: Date.now(), name: 'alpha', score: 1.5 })
|
|
86
|
+
* await store.flush()
|
|
87
|
+
* const newest = await store.sortTopK('createdAt', 'desc', 10) // int ids
|
|
88
|
+
*/
|
|
89
|
+
export class NativeColumnStore {
|
|
90
|
+
/**
|
|
91
|
+
* The Rust engine. Owns tail buffers, segments, sort/filter/range. Mutable
|
|
92
|
+
* because {@link reset} replaces it with a fresh instance to discard all
|
|
93
|
+
* native state (the napi class exposes no in-place clear).
|
|
94
|
+
*/
|
|
95
|
+
native;
|
|
96
|
+
/** Storage adapter narrowed to the binary-blob + object-path surface. */
|
|
97
|
+
storage = null;
|
|
98
|
+
/** Per-field manifests tracking persisted segments. */
|
|
99
|
+
manifests = new Map();
|
|
100
|
+
/** Per-field value type, inferred from the first value seen (matches brainy). */
|
|
101
|
+
fieldTypes = new Map();
|
|
102
|
+
/**
|
|
103
|
+
* Per-field deleted-entity bitmaps. Entity int ids here are excluded from all
|
|
104
|
+
* native queries (the Rust engine also tracks them via `deleteEntity`); the
|
|
105
|
+
* TS copy is what gets persisted to `DELETED.bin` and re-applied on init.
|
|
106
|
+
*/
|
|
107
|
+
deletedEntities = new Map();
|
|
108
|
+
/**
|
|
109
|
+
* Create a native column store. Call {@link init} before queries that need
|
|
110
|
+
* storage (loading persisted segments). Writes work immediately — the Rust
|
|
111
|
+
* tail buffer holds them in memory until {@link flush}.
|
|
112
|
+
*/
|
|
113
|
+
constructor() {
|
|
114
|
+
const bindings = loadNativeModule();
|
|
115
|
+
this.native = new bindings.NativeColumnStore();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* @description Initialize the store: narrow storage, discover persisted field
|
|
119
|
+
* manifests under `_column_index/`, load each field's segments into the Rust
|
|
120
|
+
* engine (zero-copy mmap when a local path exists), and re-apply persisted
|
|
121
|
+
* deleted bitmaps.
|
|
122
|
+
* @param storage - The brainy storage adapter (must support binary blobs).
|
|
123
|
+
* @param _idMapper - The shared entity id mapper (part of the provider
|
|
124
|
+
* contract). This native implementation returns int ids directly from
|
|
125
|
+
* sort/filter/range and lets cor's metadata index own the canonical
|
|
126
|
+
* UUID ↔ int mapping, so the mapper is accepted but not retained here.
|
|
127
|
+
* @throws If the storage adapter lacks the binary-blob primitives.
|
|
128
|
+
*/
|
|
129
|
+
async init(storage, _idMapper) {
|
|
130
|
+
if (!isBinaryBlobStorage(storage)) {
|
|
131
|
+
throw new Error('NativeColumnStore requires a storage adapter with binary-blob support ' +
|
|
132
|
+
'(saveBinaryBlob/loadBinaryBlob/deleteBinaryBlob/getBinaryBlobPath). ' +
|
|
133
|
+
'Use cor MmapFileSystemStorage or another adapter implementing BinaryBlobStorage.');
|
|
134
|
+
}
|
|
135
|
+
this.storage = storage;
|
|
136
|
+
// Discover fields by listing manifest files under the column-index prefix.
|
|
137
|
+
let paths;
|
|
138
|
+
try {
|
|
139
|
+
paths = await this.storage.listObjectsUnderPath(BASE_PATH + '/');
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
// No column index exists yet — a fresh workspace. Not an error.
|
|
143
|
+
prodLog.debug('NativeColumnStore: no existing column index to load', error);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const path of paths) {
|
|
147
|
+
if (!path.endsWith('/MANIFEST.json'))
|
|
148
|
+
continue;
|
|
149
|
+
const field = path.replace(BASE_PATH + '/', '').replace('/MANIFEST.json', '');
|
|
150
|
+
if (!field)
|
|
151
|
+
continue;
|
|
152
|
+
// Per-field fault isolation: one field that fails to load (corrupt or
|
|
153
|
+
// legacy segment, missing blob, parser mismatch) must NOT abort the
|
|
154
|
+
// discovery of every OTHER field. The previous single try/catch around
|
|
155
|
+
// the whole loop meant the first failing field silently dropped all
|
|
156
|
+
// later fields' columns — e.g. `find({ orderBy: 'name' })` returning
|
|
157
|
+
// `[]` because an earlier field threw before `name` was reached.
|
|
158
|
+
try {
|
|
159
|
+
const manifest = new ColumnManifest(field, BASE_PATH);
|
|
160
|
+
await manifest.load(this.storage);
|
|
161
|
+
this.manifests.set(field, manifest);
|
|
162
|
+
this.fieldTypes.set(field, manifest.valueType);
|
|
163
|
+
await this.loadFieldSegments(field, manifest);
|
|
164
|
+
await this.loadDeletedBitmap(field);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
prodLog.warn(`NativeColumnStore: failed to load column field '${field}' — skipping it; ` +
|
|
168
|
+
`other fields are unaffected`, error);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* @description Index an entity's field values into the Rust tail buffers.
|
|
174
|
+
* Synchronous, matching the provider contract. Value typing mirrors brainy:
|
|
175
|
+
* integer number → numeric (i64, rounded); non-integer number → float (f64);
|
|
176
|
+
* boolean → numeric 0/1; string → string. Arrays add one entry per element.
|
|
177
|
+
* `null`/`undefined` values are skipped.
|
|
178
|
+
* @param entityIntId - The entity's u32 id (from the shared id mapper).
|
|
179
|
+
* @param fields - Map of field name → value (or array of values).
|
|
180
|
+
*/
|
|
181
|
+
addEntity(entityIntId, fields) {
|
|
182
|
+
for (const [field, rawValue] of Object.entries(fields)) {
|
|
183
|
+
if (rawValue === undefined || rawValue === null)
|
|
184
|
+
continue;
|
|
185
|
+
if (Array.isArray(rawValue)) {
|
|
186
|
+
for (const v of rawValue) {
|
|
187
|
+
if (v !== undefined && v !== null) {
|
|
188
|
+
this.addValue(field, v, entityIntId);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
this.addValue(field, rawValue, entityIntId);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* @description Tombstone an entity across every known field. The Rust engine
|
|
199
|
+
* excludes it from queries immediately; the TS bitmap is persisted on flush so
|
|
200
|
+
* the tombstone survives a reopen.
|
|
201
|
+
* @param entityIntId - The entity's u32 id.
|
|
202
|
+
*/
|
|
203
|
+
removeEntity(entityIntId) {
|
|
204
|
+
// Tombstone in every field that has data (segments or tail buffer).
|
|
205
|
+
const fields = new Set([...this.manifests.keys(), ...this.fieldTypes.keys()]);
|
|
206
|
+
for (const field of fields) {
|
|
207
|
+
// Cor 3.0 Piece E: napi now accepts BigInt for the full u64
|
|
208
|
+
// range. The wrapper still surfaces u32 numbers for back-compat;
|
|
209
|
+
// brains with ids past u32::MAX need brainy 8.0's u64 wrapper.
|
|
210
|
+
this.native.deleteEntity(field, BigInt(entityIntId));
|
|
211
|
+
let deleted = this.deletedEntities.get(field);
|
|
212
|
+
if (!deleted) {
|
|
213
|
+
deleted = new RoaringBitmap32();
|
|
214
|
+
this.deletedEntities.set(field, deleted);
|
|
215
|
+
}
|
|
216
|
+
deleted.add(entityIntId);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* @description Point filter: entity int ids whose `field` equals `value`.
|
|
221
|
+
* Routes to the numeric/float/string Rust filter by the field's value type.
|
|
222
|
+
* @param field - Field to filter.
|
|
223
|
+
* @param value - Exact value to match.
|
|
224
|
+
* @returns Roaring bitmap of matching entity int ids.
|
|
225
|
+
*/
|
|
226
|
+
async filter(field, value) {
|
|
227
|
+
const type = this.fieldTypes.get(field);
|
|
228
|
+
if (type === undefined)
|
|
229
|
+
return new RoaringBitmap32();
|
|
230
|
+
let buf;
|
|
231
|
+
switch (type) {
|
|
232
|
+
case ValueType.Float: {
|
|
233
|
+
const n = this.toFloat(value);
|
|
234
|
+
if (n === undefined)
|
|
235
|
+
return new RoaringBitmap32();
|
|
236
|
+
buf = this.native.filterFloat(field, n);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
case ValueType.String:
|
|
240
|
+
buf = this.native.filterString(field, String(value));
|
|
241
|
+
break;
|
|
242
|
+
case ValueType.Number:
|
|
243
|
+
case ValueType.Boolean:
|
|
244
|
+
default: {
|
|
245
|
+
const n = this.toNumeric(value, type);
|
|
246
|
+
if (n === undefined)
|
|
247
|
+
return new RoaringBitmap32();
|
|
248
|
+
buf = this.native.filterNumeric(field, n);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return this.decodeFilterResult(buf);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* @description Decode a native column-store filter/range result into a
|
|
256
|
+
* `RoaringBitmap32`. Cor 3.0 Piece E switched the native `filter*` /
|
|
257
|
+
* `rangeQuery*` output to a croaring `Treemap` (Roaring64) portable bitmap,
|
|
258
|
+
* whose container layout is NOT interchangeable with the Roaring32 `Bitmap`
|
|
259
|
+
* portable format — decoding the Treemap buffer via `RoaringBitmap32` yields
|
|
260
|
+
* an empty set (croaring rejects the foreign cookie and returns empty). We
|
|
261
|
+
* therefore decode through `NativeRoaringBitmap64` and narrow each entity int
|
|
262
|
+
* to a `number`, lossless for the column store's entity-int range (≤ u32::MAX
|
|
263
|
+
* in every shipping deployment) and consistent with {@link sortTopK}'s
|
|
264
|
+
* `Number()` narrowing. U64 brains past that range use brainy 8.0's
|
|
265
|
+
* u64-capable wrapper.
|
|
266
|
+
* @param buf - A Roaring64 (Treemap) portable buffer from the native engine.
|
|
267
|
+
* @returns The matching entity int ids as a `RoaringBitmap32`.
|
|
268
|
+
*/
|
|
269
|
+
decodeFilterResult(buf) {
|
|
270
|
+
const bindings = loadNativeModule();
|
|
271
|
+
const ints = bindings.NativeRoaringBitmap64.deserialize(buf).toArray();
|
|
272
|
+
if (ints.length === 0)
|
|
273
|
+
return new RoaringBitmap32();
|
|
274
|
+
return new RoaringBitmap32(ints.map((v) => Number(v)));
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* @description Range filter: entity int ids whose `field` is within
|
|
278
|
+
* `[min, max]` (inclusive). Open bounds fall back to the type's extreme so a
|
|
279
|
+
* one-sided range still works. Routes by the field's value type.
|
|
280
|
+
* @param field - Field to filter.
|
|
281
|
+
* @param min - Lower bound, or undefined for no lower bound.
|
|
282
|
+
* @param max - Upper bound, or undefined for no upper bound.
|
|
283
|
+
* @returns Roaring bitmap of matching entity int ids.
|
|
284
|
+
*/
|
|
285
|
+
async rangeQuery(field, min, max) {
|
|
286
|
+
const type = this.fieldTypes.get(field);
|
|
287
|
+
if (type === undefined)
|
|
288
|
+
return new RoaringBitmap32();
|
|
289
|
+
let buf;
|
|
290
|
+
if (type === ValueType.String) {
|
|
291
|
+
// String code-point range. Empty string is the code-point minimum; the
|
|
292
|
+
// max sentinel \u{10FFFF} is the highest scalar value, so an open upper
|
|
293
|
+
// bound includes every string.
|
|
294
|
+
const lo = min !== undefined && min !== null ? String(min) : '';
|
|
295
|
+
const hi = max !== undefined && max !== null ? String(max) : '\u{10FFFF}';
|
|
296
|
+
buf = this.native.rangeQueryString(field, lo, hi);
|
|
297
|
+
}
|
|
298
|
+
else if (type === ValueType.Float) {
|
|
299
|
+
const lo = min !== undefined && min !== null ? this.toFloat(min) ?? -Infinity : -Infinity;
|
|
300
|
+
const hi = max !== undefined && max !== null ? this.toFloat(max) ?? Infinity : Infinity;
|
|
301
|
+
buf = this.native.rangeQueryFloat(field, lo, hi);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const lo = min !== undefined && min !== null
|
|
305
|
+
? this.toNumeric(min, type) ?? Number.MIN_SAFE_INTEGER
|
|
306
|
+
: Number.MIN_SAFE_INTEGER;
|
|
307
|
+
const hi = max !== undefined && max !== null
|
|
308
|
+
? this.toNumeric(max, type) ?? Number.MAX_SAFE_INTEGER
|
|
309
|
+
: Number.MAX_SAFE_INTEGER;
|
|
310
|
+
buf = this.native.rangeQueryNumeric(field, lo, hi);
|
|
311
|
+
}
|
|
312
|
+
return this.decodeFilterResult(buf);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* @description Sort top-K: the K entity int ids with the highest (`desc`) or
|
|
316
|
+
* lowest (`asc`) values in `field`, in sort order. This is the method brainy's
|
|
317
|
+
* unfiltered `orderBy` path calls. The Rust engine performs the k-way merge
|
|
318
|
+
* across sorted segments + the (per-query sorted) tail buffer and excludes
|
|
319
|
+
* tombstoned ids.
|
|
320
|
+
* @param field - Field to sort by.
|
|
321
|
+
* @param order - 'asc' or 'desc'.
|
|
322
|
+
* @param k - Maximum number of ids to return.
|
|
323
|
+
* @returns Sorted array of entity int ids.
|
|
324
|
+
*/
|
|
325
|
+
async sortTopK(field, order, k) {
|
|
326
|
+
if (k <= 0)
|
|
327
|
+
return [];
|
|
328
|
+
// Cor 3.0 Piece E: napi returns BigInt[]. The wrapper narrows
|
|
329
|
+
// to number for back-compat (safe for ids ≤ u32::MAX). U64 brains
|
|
330
|
+
// need brainy 8.0's u64-capable wrapper.
|
|
331
|
+
return this.native.sortTopK(field, order, k).map(id => Number(id));
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* @description Filtered sort top-K: {@link sortTopK} restricted to ids present
|
|
335
|
+
* in `filterBitmap`. Implemented as sort-then-intersect: the native sort is
|
|
336
|
+
* pulled deep enough that the post-filter still yields up to K ids, then the
|
|
337
|
+
* filter bitmap is applied in order.
|
|
338
|
+
* @param filterBitmap - Candidate entity int ids.
|
|
339
|
+
* @param field - Field to sort by.
|
|
340
|
+
* @param order - 'asc' or 'desc'.
|
|
341
|
+
* @param k - Maximum number of ids to return.
|
|
342
|
+
* @returns Sorted array of entity int ids, all present in `filterBitmap`.
|
|
343
|
+
*/
|
|
344
|
+
async filteredSortTopK(filterBitmap, field, order, k) {
|
|
345
|
+
if (k <= 0 || filterBitmap.size === 0)
|
|
346
|
+
return [];
|
|
347
|
+
// Intersecting only the global top-K could drop matches when the filter is
|
|
348
|
+
// selective and non-matching entities sort ahead of the matches. Sort the
|
|
349
|
+
// ENTIRE field (a u32::MAX cap returns every entry; the Rust engine clamps
|
|
350
|
+
// to the field's actual size) and intersect in sort order — correct at any
|
|
351
|
+
// selectivity. The early break keeps the scan to the first K matches.
|
|
352
|
+
// Cor 3.0 Piece E: napi returns BigInt[]. Narrow per iteration
|
|
353
|
+
// so we can use the Number version against RoaringBitmap32.
|
|
354
|
+
const sorted = this.native.sortTopK(field, order, 0xffffffff);
|
|
355
|
+
const result = [];
|
|
356
|
+
for (const idBig of sorted) {
|
|
357
|
+
const id = Number(idBig);
|
|
358
|
+
if (filterBitmap.has(id)) {
|
|
359
|
+
result.push(id);
|
|
360
|
+
if (result.length >= k)
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* @description All distinct values for a field, as strings in ascending order
|
|
368
|
+
* (numeric by value, string by code point), backed by the Rust engine's
|
|
369
|
+
* segment + tail scan.
|
|
370
|
+
* @param field - Field name.
|
|
371
|
+
* @returns Distinct values stringified the way brainy renders them.
|
|
372
|
+
*/
|
|
373
|
+
async getFilterValues(field) {
|
|
374
|
+
if (!this.native.hasField(field))
|
|
375
|
+
return [];
|
|
376
|
+
return this.native.distinctValues(field);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* @description Whether a field has any indexed data (a persisted segment or
|
|
380
|
+
* buffered tail entry) in the Rust engine.
|
|
381
|
+
* @param field - Field name.
|
|
382
|
+
* @returns True if the field is queryable.
|
|
383
|
+
*/
|
|
384
|
+
hasField(field) {
|
|
385
|
+
return this.native.hasField(field);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* @description Flush every field's tail buffer to a new L0 `.cidx` segment,
|
|
389
|
+
* persist the segment as a raw binary blob, update the manifest, and persist
|
|
390
|
+
* the deleted bitmap. No-op if {@link init} has not run (no storage to write).
|
|
391
|
+
*/
|
|
392
|
+
async flush() {
|
|
393
|
+
if (!this.storage)
|
|
394
|
+
return;
|
|
395
|
+
// Snapshot field names: flushing drains the Rust tail buffer for a field, so
|
|
396
|
+
// iterate over a stable set (tail fields + already-persisted fields).
|
|
397
|
+
const fields = new Set([...this.fieldTypes.keys(), ...this.manifests.keys()]);
|
|
398
|
+
for (const field of fields) {
|
|
399
|
+
await this.flushField(field);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* @description Flush and release all resources. Persists pending writes first.
|
|
404
|
+
*/
|
|
405
|
+
async close() {
|
|
406
|
+
if (this.storage)
|
|
407
|
+
await this.flush();
|
|
408
|
+
this.manifests.clear();
|
|
409
|
+
this.fieldTypes.clear();
|
|
410
|
+
this.deletedEntities.clear();
|
|
411
|
+
this.storage = null;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* @description Discard all column-store state — in-memory AND persisted — and
|
|
415
|
+
* start fresh. Called when the owning metadata index rebuilds or clears: a
|
|
416
|
+
* rebuild reassigns entity int ids from scratch, so any persisted `.cidx`
|
|
417
|
+
* segments (which encode the OLD ids) would point at the wrong entities. This
|
|
418
|
+
* drops the native engine (a fresh instance discards all tail buffers and
|
|
419
|
+
* loaded segments), clears the manifests/value-types/deleted bitmaps, and
|
|
420
|
+
* deletes every persisted file under `_column_index/`. The store remains
|
|
421
|
+
* initialized and ready: the rebuild's subsequent `addEntity` calls repopulate
|
|
422
|
+
* it consistently with the new id assignments.
|
|
423
|
+
*/
|
|
424
|
+
async reset() {
|
|
425
|
+
// Delete persisted column-index files (manifests + segment/deleted blobs)
|
|
426
|
+
// before dropping the in-memory manifests we need to locate them.
|
|
427
|
+
if (this.storage) {
|
|
428
|
+
for (const [field, manifest] of this.manifests) {
|
|
429
|
+
try {
|
|
430
|
+
await this.storage.deleteObjectFromPath(manifest.manifestPath());
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
/* manifest already gone */
|
|
434
|
+
}
|
|
435
|
+
for (const seg of manifest.getAllSegments()) {
|
|
436
|
+
await this.storage.deleteBinaryBlob(segmentBlobKey(field, seg.level, seg.id));
|
|
437
|
+
}
|
|
438
|
+
await this.storage.deleteBinaryBlob(deletedBlobKey(field));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Drop all native state by replacing the engine, then clear TS-side state.
|
|
442
|
+
const bindings = loadNativeModule();
|
|
443
|
+
this.native = new bindings.NativeColumnStore();
|
|
444
|
+
this.manifests.clear();
|
|
445
|
+
this.fieldTypes.clear();
|
|
446
|
+
this.deletedEntities.clear();
|
|
447
|
+
}
|
|
448
|
+
// =========================================================================
|
|
449
|
+
// Internal helpers
|
|
450
|
+
// =========================================================================
|
|
451
|
+
/**
|
|
452
|
+
* @description Add a single value to a field, inferring + recording the value
|
|
453
|
+
* type on first write and routing to the matching Rust tail buffer.
|
|
454
|
+
* @param field - Field name.
|
|
455
|
+
* @param value - A non-null primitive value.
|
|
456
|
+
* @param entityIntId - The entity's u32 id.
|
|
457
|
+
*/
|
|
458
|
+
addValue(field, value, entityIntId) {
|
|
459
|
+
let type = this.fieldTypes.get(field);
|
|
460
|
+
if (type === undefined) {
|
|
461
|
+
type = inferValueType(value);
|
|
462
|
+
this.fieldTypes.set(field, type);
|
|
463
|
+
// Ensure a manifest exists so flush has somewhere to record segments.
|
|
464
|
+
if (!this.manifests.has(field)) {
|
|
465
|
+
const manifest = new ColumnManifest(field, BASE_PATH);
|
|
466
|
+
manifest.valueType = type;
|
|
467
|
+
this.manifests.set(field, manifest);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// Cor 3.0 Piece E: napi expects BigInt entity ids. Wrapper
|
|
471
|
+
// bridges from u32 number for back-compat; brainy 8.0 wrapper
|
|
472
|
+
// will accept BigInt directly.
|
|
473
|
+
const idBig = BigInt(entityIntId);
|
|
474
|
+
switch (type) {
|
|
475
|
+
case ValueType.Float: {
|
|
476
|
+
const n = this.toFloat(value);
|
|
477
|
+
if (n !== undefined)
|
|
478
|
+
this.native.addFloat(field, n, idBig);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
case ValueType.String:
|
|
482
|
+
this.native.addString(field, String(value), idBig);
|
|
483
|
+
break;
|
|
484
|
+
case ValueType.Number:
|
|
485
|
+
case ValueType.Boolean:
|
|
486
|
+
default: {
|
|
487
|
+
const n = this.toNumeric(value, type);
|
|
488
|
+
if (n !== undefined)
|
|
489
|
+
this.native.addNumeric(field, n, idBig);
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* @description Flush a single field: drain its Rust tail buffer to a `.cidx`
|
|
496
|
+
* segment buffer, persist it as a binary blob, register it in the manifest,
|
|
497
|
+
* then persist the manifest and (if any) the deleted bitmap.
|
|
498
|
+
* @param field - Field to flush.
|
|
499
|
+
*/
|
|
500
|
+
async flushField(field) {
|
|
501
|
+
if (!this.storage)
|
|
502
|
+
return;
|
|
503
|
+
let manifest = this.manifests.get(field);
|
|
504
|
+
if (!manifest) {
|
|
505
|
+
manifest = new ColumnManifest(field, BASE_PATH);
|
|
506
|
+
const type = this.fieldTypes.get(field);
|
|
507
|
+
if (type !== undefined)
|
|
508
|
+
manifest.valueType = type;
|
|
509
|
+
this.manifests.set(field, manifest);
|
|
510
|
+
}
|
|
511
|
+
// Drain the tail buffer to a sorted segment, if it has entries.
|
|
512
|
+
if (this.native.tailBufferSize(field) > 0) {
|
|
513
|
+
const segId = manifest.nextSegmentId();
|
|
514
|
+
const entryCount = this.native.tailBufferSize(field);
|
|
515
|
+
const segBuffer = this.native.flushTailBuffer(field, 0, segId);
|
|
516
|
+
if (segBuffer) {
|
|
517
|
+
const key = segmentBlobKey(field, 0, segId);
|
|
518
|
+
await this.storage.saveBinaryBlob(key, segBuffer);
|
|
519
|
+
manifest.addSegment({
|
|
520
|
+
id: segId,
|
|
521
|
+
level: 0,
|
|
522
|
+
// Number of entries drained into this segment. min/max zone-map values
|
|
523
|
+
// are derived from the sorted segment by the Rust reader, so 0 is a
|
|
524
|
+
// safe placeholder here (cor's metadata index does native
|
|
525
|
+
// filtering; the column store's job is populate/persist/sort).
|
|
526
|
+
count: entryCount,
|
|
527
|
+
minValue: 0,
|
|
528
|
+
maxValue: 0,
|
|
529
|
+
file: `L0-${String(segId).padStart(6, '0')}.cidx`
|
|
530
|
+
});
|
|
531
|
+
// flushTailBuffer DRAINS the Rust tail buffer — the data is now only in
|
|
532
|
+
// the persisted segment. Reload that segment back into the engine so the
|
|
533
|
+
// field stays queryable in this same session (sortTopK/filter/range
|
|
534
|
+
// would otherwise see neither tail nor segment until a reopen). Prefer
|
|
535
|
+
// zero-copy mmap; fall back to the just-written buffer for remote
|
|
536
|
+
// adapters with no local path.
|
|
537
|
+
await this.loadSegmentIntoEngine(field, segId, 0, segBuffer);
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
// Tail reported entries but produced no buffer — roll back the id so the
|
|
541
|
+
// manifest stays consistent. (Defensive; flushTailBuffer only returns
|
|
542
|
+
// null for an empty tail.)
|
|
543
|
+
manifest.rollbackSegmentId();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Persist the deleted bitmap alongside the manifest, so tombstones survive.
|
|
547
|
+
const deleted = this.deletedEntities.get(field);
|
|
548
|
+
if (deleted && deleted.size > 0) {
|
|
549
|
+
await this.storage.saveBinaryBlob(deletedBlobKey(field), deleted.serialize('portable'));
|
|
550
|
+
}
|
|
551
|
+
await manifest.save(this.storage);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* @description Load all of a field's persisted segments into the Rust engine.
|
|
555
|
+
* Used on init, where no segment buffer is held in hand, so each segment is
|
|
556
|
+
* loaded by mmap path or read from storage.
|
|
557
|
+
* @param field - Field name.
|
|
558
|
+
* @param manifest - The field's loaded manifest.
|
|
559
|
+
*/
|
|
560
|
+
async loadFieldSegments(field, manifest) {
|
|
561
|
+
if (!this.storage)
|
|
562
|
+
return;
|
|
563
|
+
for (const seg of manifest.getAllSegments()) {
|
|
564
|
+
await this.loadSegmentIntoEngine(field, seg.id, seg.level);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* @description Load one persisted segment into the Rust engine, preferring
|
|
569
|
+
* zero-copy mmap (`loadSegmentFromPath`) when the adapter exposes a local
|
|
570
|
+
* path. Falls back to `fallbackBuffer` if provided (the just-flushed bytes),
|
|
571
|
+
* otherwise reads the blob from storage. Used both on init (reload all
|
|
572
|
+
* segments) and on flush (re-attach the segment a drained tail just produced).
|
|
573
|
+
* @param field - Field name.
|
|
574
|
+
* @param segId - Segment id.
|
|
575
|
+
* @param level - Segment compaction level.
|
|
576
|
+
* @param fallbackBuffer - Segment bytes already in memory (flush path), if any.
|
|
577
|
+
*/
|
|
578
|
+
async loadSegmentIntoEngine(field, segId, level, fallbackBuffer) {
|
|
579
|
+
if (!this.storage)
|
|
580
|
+
return;
|
|
581
|
+
const key = segmentBlobKey(field, level, segId);
|
|
582
|
+
const path = this.storage.getBinaryBlobPath(key);
|
|
583
|
+
// Only mmap when the raw-blob file actually EXISTS. `getBinaryBlobPath`
|
|
584
|
+
// returns a path string whether or not the file is present, and
|
|
585
|
+
// `loadSegmentFromPath` does NOT fault on a missing file — it loads a
|
|
586
|
+
// silently-empty segment. Guard so a missing raw blob falls through to the
|
|
587
|
+
// buffer path below instead of "succeeding" empty.
|
|
588
|
+
if (path && existsSync(path)) {
|
|
589
|
+
try {
|
|
590
|
+
this.native.loadSegmentFromPath(field, String(segId), path);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
// Path existed but the file was not mmap-able — e.g. a remote adapter
|
|
595
|
+
// returning a synthetic path. Fall through to the buffer / legacy path.
|
|
596
|
+
prodLog.debug(`NativeColumnStore: mmap load failed for ${key}, trying buffer`, error);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const buf = fallbackBuffer ?? (await this.storage.loadBinaryBlob(key));
|
|
600
|
+
if (buf) {
|
|
601
|
+
this.native.loadSegmentFromBuffer(field, String(segId), buf);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
// No raw blob for this segment. A 2.x→3.0 upgrade does NOT read legacy
|
|
605
|
+
// column envelopes in place — it rebuilds the index from entities (the
|
|
606
|
+
// clean-break upgrade path), so a missing raw blob here is simply an
|
|
607
|
+
// absent segment, not a format to migrate.
|
|
608
|
+
prodLog.warn(`NativeColumnStore: segment blob missing for ${key}`);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* @description Load and re-apply a field's persisted deleted bitmap by calling
|
|
612
|
+
* `deleteEntity` for each id, so tombstones survive a reopen.
|
|
613
|
+
* @param field - Field name.
|
|
614
|
+
*/
|
|
615
|
+
async loadDeletedBitmap(field) {
|
|
616
|
+
if (!this.storage)
|
|
617
|
+
return;
|
|
618
|
+
const buf = await this.storage.loadBinaryBlob(deletedBlobKey(field));
|
|
619
|
+
if (!buf)
|
|
620
|
+
return;
|
|
621
|
+
const bitmap = RoaringBitmap32.deserialize(buf, 'portable');
|
|
622
|
+
this.deletedEntities.set(field, bitmap);
|
|
623
|
+
for (const id of bitmap) {
|
|
624
|
+
// Cor 3.0 Piece E: BigInt at the napi boundary.
|
|
625
|
+
this.native.deleteEntity(field, BigInt(id));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* @description Coerce a JS value to the i64 numeric column representation,
|
|
630
|
+
* matching brainy's `normalizeValue` for `Number`/`Boolean`.
|
|
631
|
+
* @param value - The raw value.
|
|
632
|
+
* @param type - The target value type (`Number` or `Boolean`).
|
|
633
|
+
* @returns The integer value, or undefined if not coercible.
|
|
634
|
+
*/
|
|
635
|
+
toNumeric(value, type) {
|
|
636
|
+
if (type === ValueType.Boolean) {
|
|
637
|
+
if (typeof value === 'boolean')
|
|
638
|
+
return value ? 1 : 0;
|
|
639
|
+
if (typeof value === 'number')
|
|
640
|
+
return value ? 1 : 0;
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
// ValueType.Number
|
|
644
|
+
if (typeof value === 'number')
|
|
645
|
+
return Math.round(value);
|
|
646
|
+
if (typeof value === 'boolean')
|
|
647
|
+
return value ? 1 : 0;
|
|
648
|
+
if (typeof value === 'string') {
|
|
649
|
+
const n = Number(value);
|
|
650
|
+
return Number.isNaN(n) ? undefined : Math.round(n);
|
|
651
|
+
}
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* @description Coerce a JS value to the f64 float column representation,
|
|
656
|
+
* matching brainy's `normalizeValue` for `Float`.
|
|
657
|
+
* @param value - The raw value.
|
|
658
|
+
* @returns The float value, or undefined if not coercible.
|
|
659
|
+
*/
|
|
660
|
+
toFloat(value) {
|
|
661
|
+
if (typeof value === 'number')
|
|
662
|
+
return value;
|
|
663
|
+
if (typeof value === 'string') {
|
|
664
|
+
const n = Number(value);
|
|
665
|
+
return Number.isNaN(n) ? undefined : n;
|
|
666
|
+
}
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* @description Infer the column `ValueType` from a JS value, identical to
|
|
672
|
+
* brainy's `ColumnStore.inferValueType`: boolean → Boolean; integer number →
|
|
673
|
+
* Number; non-integer number → Float; everything else → String.
|
|
674
|
+
* @param value - A non-null value.
|
|
675
|
+
* @returns The inferred value type.
|
|
676
|
+
*/
|
|
677
|
+
export function inferValueType(value) {
|
|
678
|
+
if (typeof value === 'boolean')
|
|
679
|
+
return ValueType.Boolean;
|
|
680
|
+
if (typeof value === 'number') {
|
|
681
|
+
return Number.isInteger(value) ? ValueType.Number : ValueType.Float;
|
|
682
|
+
}
|
|
683
|
+
return ValueType.String;
|
|
684
|
+
}
|
|
685
|
+
//# sourceMappingURL=NativeColumnStore.js.map
|