@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.
Files changed (87) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +328 -0
  3. package/dist/aggregation/NativeAggregationEngine.d.ts +118 -0
  4. package/dist/aggregation/NativeAggregationEngine.js +186 -0
  5. package/dist/cli.d.ts +12 -0
  6. package/dist/cli.js +236 -0
  7. package/dist/graph/GraphAccelerationAdapter.d.ts +153 -0
  8. package/dist/graph/GraphAccelerationAdapter.js +326 -0
  9. package/dist/graph/NativeGraphAdjacencyIndex.d.ts +286 -0
  10. package/dist/graph/NativeGraphAdjacencyIndex.js +944 -0
  11. package/dist/hnsw/AdaptiveDiskAnnModeSelector.d.ts +187 -0
  12. package/dist/hnsw/AdaptiveDiskAnnModeSelector.js +313 -0
  13. package/dist/hnsw/NativeDiskAnnWrapper.d.ts +489 -0
  14. package/dist/hnsw/NativeDiskAnnWrapper.js +1456 -0
  15. package/dist/index.d.ts +28 -0
  16. package/dist/index.js +29 -0
  17. package/dist/legacyLayoutGuard.d.ts +93 -0
  18. package/dist/legacyLayoutGuard.js +237 -0
  19. package/dist/license/constants.d.ts +30 -0
  20. package/dist/license/constants.js +30 -0
  21. package/dist/license/onlineValidator.d.ts +43 -0
  22. package/dist/license/onlineValidator.js +225 -0
  23. package/dist/license/types.d.ts +39 -0
  24. package/dist/license/types.js +5 -0
  25. package/dist/license.d.ts +64 -0
  26. package/dist/license.js +165 -0
  27. package/dist/migration/MigrationCoordinator.d.ts +171 -0
  28. package/dist/migration/MigrationCoordinator.js +249 -0
  29. package/dist/migration/indexEpochGuard.d.ts +163 -0
  30. package/dist/migration/indexEpochGuard.js +202 -0
  31. package/dist/native/NativeEmbeddingEngine.d.ts +79 -0
  32. package/dist/native/NativeEmbeddingEngine.js +318 -0
  33. package/dist/native/NativeRoaringBitmap32.d.ts +114 -0
  34. package/dist/native/NativeRoaringBitmap32.js +221 -0
  35. package/dist/native/ffi.d.ts +20 -0
  36. package/dist/native/ffi.js +48 -0
  37. package/dist/native/idMapperTestSupport.d.ts +67 -0
  38. package/dist/native/idMapperTestSupport.js +112 -0
  39. package/dist/native/index.d.ts +49 -0
  40. package/dist/native/index.js +87 -0
  41. package/dist/native/napi.d.ts +21 -0
  42. package/dist/native/napi.js +88 -0
  43. package/dist/native/types.d.ts +1298 -0
  44. package/dist/native/types.js +16 -0
  45. package/dist/plugin.d.ts +50 -0
  46. package/dist/plugin.js +388 -0
  47. package/dist/providerContracts.d.ts +277 -0
  48. package/dist/providerContracts.js +38 -0
  49. package/dist/resource/OsMemoryProbe.d.ts +175 -0
  50. package/dist/resource/OsMemoryProbe.js +206 -0
  51. package/dist/resource/ResourceManager.d.ts +491 -0
  52. package/dist/resource/ResourceManager.js +960 -0
  53. package/dist/utils/ColumnManifest.d.ts +97 -0
  54. package/dist/utils/ColumnManifest.js +129 -0
  55. package/dist/utils/NativeColumnStore.d.ts +284 -0
  56. package/dist/utils/NativeColumnStore.js +685 -0
  57. package/dist/utils/NativeMetadataIndex.d.ts +882 -0
  58. package/dist/utils/NativeMetadataIndex.js +2631 -0
  59. package/dist/utils/NativeUnifiedCache.d.ts +87 -0
  60. package/dist/utils/NativeUnifiedCache.js +273 -0
  61. package/dist/utils/binaryIdMapperFactory.d.ts +59 -0
  62. package/dist/utils/binaryIdMapperFactory.js +94 -0
  63. package/dist/utils/collation.d.ts +30 -0
  64. package/dist/utils/collation.js +40 -0
  65. package/dist/utils/columnStoreTypes.d.ts +161 -0
  66. package/dist/utils/columnStoreTypes.js +29 -0
  67. package/dist/utils/nativeBinaryEntityIdMapper.d.ts +211 -0
  68. package/dist/utils/nativeBinaryEntityIdMapper.js +381 -0
  69. package/dist/utils/nativeEntityIdMapper.d.ts +111 -0
  70. package/dist/utils/nativeEntityIdMapper.js +170 -0
  71. package/docs/ADR-002-diskann-100-percent-rust.md +294 -0
  72. package/docs/ADR-003-semantic-time-travel.md +100 -0
  73. package/docs/aggregation.md +251 -0
  74. package/docs/comparison.md +162 -0
  75. package/docs/deployment-limits.md +87 -0
  76. package/docs/diskann.md +184 -0
  77. package/docs/migration-3.0.md +396 -0
  78. package/docs/performance-budget.md +274 -0
  79. package/docs/performance.md +117 -0
  80. package/docs/scaling.md +439 -0
  81. package/docs/snapshot-safety.md +246 -0
  82. package/docs/u64-id-space.md +214 -0
  83. package/docs/verification-report.md +670 -0
  84. package/native/brainy-native.node +0 -0
  85. package/native/index.d.ts +3916 -0
  86. package/package.json +92 -0
  87. package/scripts/migrate-cortex-2x-to-3x.mjs +970 -0
@@ -0,0 +1,944 @@
1
+ /**
2
+ * NativeGraphAdjacencyIndex — Thin TS wrapper around the Rust graph engine
3
+ *
4
+ * Handles async storage I/O while the Rust engine handles all in-memory
5
+ * data operations (4 LSM-trees, verb tracking, relationship counts).
6
+ *
7
+ * Storage pattern: adapter-controlled I/O with 3-tier fallback.
8
+ * - Tier 1 (mmap): saveBinaryBlob + getBinaryBlobPath → zero-copy queries
9
+ * - Tier 2 (binary blob): saveBinaryBlob only → binary format in memory
10
+ * - Tier 3 (legacy): saveMetadata → base64 JSON
11
+ *
12
+ * UnifiedCache integration stays in TS (getVerbCached, getVerbsBatchCached)
13
+ * since it coordinates cross-subsystem caching and requires async storage access.
14
+ */
15
+ import { dirname } from 'node:path';
16
+ import { TypeUtils, VerbType } from '@soulcraft/brainy/types/graphTypes';
17
+ import { loadNativeModule } from '../native/index.js';
18
+ import { getGlobalCache, prodLog } from '@soulcraft/brainy/internals';
19
+ /**
20
+ * Fallback verb-type index for unknown / missing verb-type strings.
21
+ * Mirrors cor's `VERB_FALLBACK_INDEX` from `graph_types.rs` and
22
+ * brainy's `TypeUtils.getVerbIndex` runtime fallback to
23
+ * `VerbType.RelatedTo`. Resolved once at module load — the lookup
24
+ * itself is sub-microsecond but the constant is part of the
25
+ * closed-vocabulary contract documented at
26
+ * [[nountype-verbtype-closed-vocabulary]].
27
+ */
28
+ const VERB_FALLBACK_INDEX = TypeUtils.getVerbIndex(VerbType.RelatedTo) ?? 3;
29
+ /**
30
+ * Blob key probed to derive the durable verb-ID namespace directory when
31
+ * the caller didn't pass an explicit {@link GraphIndexConfig.persistenceDir}.
32
+ *
33
+ * On a filesystem-backed adapter, `getBinaryBlobPath(key)` resolves to a real
34
+ * on-disk path such as `<root>/_blobs/_graph_verb_ns/x.bin`; taking its
35
+ * `dirname` yields a local, per-brain directory (`<root>/_blobs/_graph_verb_ns`)
36
+ * that sits under the storage root ALONGSIDE the graph LSM SSTables
37
+ * (`<root>/_blobs/graph-lsm/…`), so brainy's hard-link snapshot captures the
38
+ * verb namespace + endpoints files together with the SSTables coherently.
39
+ *
40
+ * The trailing `/x` segment exists only so `dirname` peels back to the
41
+ * dedicated directory; no blob is ever written at this exact key — the native
42
+ * `VerbIdNamespace` owns the files inside the directory
43
+ * (`verb-uuid-to-int.mkv`, `verb-int-to-uuid.bin`, `verb-endpoints/`).
44
+ */
45
+ const GRAPH_VERB_NS_PROBE_KEY = '_graph_verb_ns/x';
46
+ /**
47
+ * Resolve a verb-type string to its canonical `VerbTypeEnum` index,
48
+ * falling back to {@link VERB_FALLBACK_INDEX} (`VerbType.RelatedTo`)
49
+ * for unknown / missing input. Avoids two FFI hops by doing the
50
+ * lookup in TS via brainy's `TypeUtils`, then handing the index
51
+ * directly to the native `addVerbByIndex` / `trackVerbIdByIndex`
52
+ * surface.
53
+ */
54
+ function resolveVerbTypeIndex(verbType) {
55
+ if (!verbType)
56
+ return VERB_FALLBACK_INDEX;
57
+ const idx = TypeUtils.getVerbIndex(verbType);
58
+ return idx ?? VERB_FALLBACK_INDEX;
59
+ }
60
+ // Storage prefixes for each tree
61
+ const TREE_PREFIXES = {
62
+ 'source': 'graph-lsm-source',
63
+ 'target': 'graph-lsm-target',
64
+ 'verbs-source': 'graph-lsm-verbs-source',
65
+ 'verbs-target': 'graph-lsm-verbs-target',
66
+ };
67
+ /**
68
+ * Native 4-LSM-tree graph adjacency index. Implements {@link GraphIndexProvider}
69
+ * so a compile error fires the moment this wrapper drops or drifts a member
70
+ * brainy calls via `this.graphIndex.*` (e.g. the `isInitialized` getter).
71
+ */
72
+ export class GraphAdjacencyIndex {
73
+ migrationCoordinatorGetter;
74
+ storage;
75
+ native;
76
+ unifiedCache;
77
+ config;
78
+ initialized = false;
79
+ isRebuilding = false;
80
+ flushTimer;
81
+ rebuildStartTime = 0;
82
+ totalRelationshipsIndexed = 0;
83
+ isCompacting = {};
84
+ // Adapter capability detection (set in constructor)
85
+ hasBinaryBlobs;
86
+ hasMmapPaths;
87
+ /**
88
+ * Lazy resolver to the shared entity-id mapper (the metadata index's mapper —
89
+ * the one brainy resolves verb endpoints through at write time). Used ONLY by
90
+ * `rebuild()` to turn persisted `verb.sourceId`/`targetId` UUIDs back into the
91
+ * entity ints the 4 LSM adjacency trees are keyed on. Lazy because the
92
+ * metadata-index instance may be constructed after this one; it is resolved at
93
+ * rebuild time (post-init), by when both providers exist. `undefined` when cor
94
+ * is used without a metadata index — `rebuild()` then degrades to the
95
+ * membership-only pass and says so. (#68)
96
+ */
97
+ mapperResolver;
98
+ get isInitialized() {
99
+ return this.initialized;
100
+ }
101
+ /**
102
+ * The underlying native graph engine handle. Exposed so the
103
+ * `graphAcceleration` provider adapter ({@link GraphAccelerationAdapter})
104
+ * can drive `traverse` / the streaming cursor against the SAME native
105
+ * instance this provider already holds — never a second index (which
106
+ * would split state + double RAM). Read-only by contract: the adapter
107
+ * only calls read/cursor methods on it.
108
+ */
109
+ get nativeInstance() {
110
+ return this.native;
111
+ }
112
+ constructor(storage, config = {}, mapperResolver,
113
+ /**
114
+ * Lazy resolver to the shared {@link MigrationCoordinator} — the same getter
115
+ * the metadata + vector providers receive, so Brainy's feature-detected lock
116
+ * methods return a consistent answer across all three. Undefined when no
117
+ * migration path is wired.
118
+ */
119
+ migrationCoordinatorGetter) {
120
+ this.migrationCoordinatorGetter = migrationCoordinatorGetter;
121
+ this.storage = storage;
122
+ this.mapperResolver = mapperResolver;
123
+ // **related()-correctness + billion-scale RAM (#23).** When the caller
124
+ // did not pass an explicit `persistenceDir` but the storage adapter is
125
+ // filesystem-backed, derive one from a dedicated blob key so the native
126
+ // engine selects the DURABLE mmap `VerbIdNamespace` (+ mmap
127
+ // `VerbEndpointsStore`) instead of the in-memory fallback. This matters
128
+ // for correctness, not just speed:
129
+ // • The in-memory namespace's reverse map (verbInt → verb-UUID) is a
130
+ // no-op — `get_uuid` returns `None` — so `verbIntsToIds()` yields
131
+ // `[null]`, brainy filters those out, and `getVerbsBySource()` /
132
+ // `related()` come back empty even though the edge exists.
133
+ // • The in-memory forward map is O(edges)-resident (~75–85 GB at 1B
134
+ // edges) — a live RAM-invariant violation; the mmap namespace is
135
+ // demand-paged (O(working-set)).
136
+ // • The in-memory map re-interns from zero on reopen, so the fresh ints
137
+ // never match the durable LSM's baked ints; only the persisted mmap
138
+ // namespace is reopen-coherent.
139
+ //
140
+ // **Tier guard — gate on the RETURN VALUE, not method presence.**
141
+ // `getBinaryBlobPath` is an ABSTRACT member every adapter implements; the
142
+ // cloud/memory adapters implement it and return `null`. A `typeof … ===
143
+ // 'function'` guard would mis-route cloud into the filesystem path and
144
+ // hand native a bogus directory. So we invoke it and branch strictly on a
145
+ // non-null path. Cloud/memory return `null` → `persistenceDir` stays
146
+ // undefined → the in-memory namespace, unchanged (durable cloud
147
+ // materialization is task #26). Never throws for cloud.
148
+ let persistenceDir = config.persistenceDir;
149
+ if (persistenceDir === undefined) {
150
+ const probePath = storage.getBinaryBlobPath?.(GRAPH_VERB_NS_PROBE_KEY);
151
+ if (probePath) {
152
+ persistenceDir = dirname(probePath);
153
+ }
154
+ }
155
+ this.config = {
156
+ maxIndexSize: config.maxIndexSize ?? 100000,
157
+ rebuildThreshold: config.rebuildThreshold ?? 0.1,
158
+ autoOptimize: config.autoOptimize ?? true,
159
+ flushInterval: config.flushInterval ?? 30000,
160
+ memTableThreshold: config.memTableThreshold ?? 100000,
161
+ maxSSTablesPerLevel: config.maxSSTablesPerLevel ?? 10,
162
+ persistenceDir,
163
+ };
164
+ const bindings = loadNativeModule();
165
+ // Cor 3.0 zero-config: graph wrapper passes verbIdSpace='U64'
166
+ // so the verb-ID interning namespace + live-verb / per-(type,
167
+ // subtype) membership bitmaps support `u64::MAX - 1` verb IDs
168
+ // out of the box. The raw napi GraphAdjacencyConfig still
169
+ // defaults to 'U32' for direct-napi back-compat — the production
170
+ // path goes through this wrapper.
171
+ this.native = new bindings.NativeGraphAdjacencyIndex({
172
+ memTableThreshold: this.config.memTableThreshold,
173
+ maxSstablesPerLevel: this.config.maxSSTablesPerLevel,
174
+ persistenceDir: this.config.persistenceDir,
175
+ verbIdSpace: 'U64',
176
+ });
177
+ this.unifiedCache = getGlobalCache();
178
+ // Detect adapter capabilities via optional method presence
179
+ this.hasBinaryBlobs = typeof storage.saveBinaryBlob === 'function';
180
+ this.hasMmapPaths = typeof storage.getBinaryBlobPath === 'function';
181
+ if (this.hasMmapPaths) {
182
+ prodLog.info('GraphAdjacencyIndex initialized with native Rust engine (4 embedded LSM-trees, mmap SSTable I/O)');
183
+ }
184
+ else if (this.hasBinaryBlobs) {
185
+ prodLog.info('GraphAdjacencyIndex initialized with native Rust engine (4 embedded LSM-trees, binary blob I/O)');
186
+ }
187
+ else {
188
+ prodLog.info('GraphAdjacencyIndex initialized with native Rust engine (4 embedded LSM-trees)');
189
+ }
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // Initialization
193
+ // ---------------------------------------------------------------------------
194
+ /**
195
+ * **Eager cold-load entry — brainy 8.0 rebuild gate (#36).** Brainy
196
+ * awaits this right after `metadataIndex.init()` and BEFORE it consults
197
+ * {@link isReady}, so the durable graph is restored (the four u64 LSM
198
+ * trees cold-loaded from their `.sst` blobs) before the gate reads
199
+ * readiness — otherwise `isReady()` would be `false` until the first
200
+ * lazy op and brainy would needlessly rebuild a graph that is already on
201
+ * disk. Idempotent and a no-op for an in-memory index; mirrors
202
+ * {@link MetadataIndexProvider.init}. Delegates to the same
203
+ * {@link ensureInitialized} guard the lazy read/mutation paths use, so
204
+ * an index that is never `init()`-ed still cold-loads on first touch.
205
+ */
206
+ async init() {
207
+ await this.ensureInitialized();
208
+ }
209
+ /**
210
+ * Idempotent lazy-init guard. Runs the cold-load
211
+ * ({@link openGraphLsmEngine}) exactly once, then arms auto-flush. Both
212
+ * the eager {@link init} and every read/mutation path funnel through
213
+ * here, so a caller that skips `init()` still gets a fully cold-loaded
214
+ * graph on first touch.
215
+ */
216
+ async ensureInitialized() {
217
+ if (this.initialized) {
218
+ return;
219
+ }
220
+ await this.openGraphLsmEngine();
221
+ this.startAutoFlush();
222
+ this.initialized = true;
223
+ }
224
+ /**
225
+ * Cold-load the four u64 LSM trees from persistence: load each tree's
226
+ * manifest and register every persisted SSTable (mmap tier, then binary
227
+ * blob tier) so the adjacency is queryable with NO rebuild. Each SSTable
228
+ * load latches the native `is_ready()` flag, so {@link isReady} reports
229
+ * `true` the instant durable edges are mapped in.
230
+ *
231
+ * The trailing membership-consistency walk is the **deferred O(N)
232
+ * fast-follow** (#19): it repopulates the verb-id membership bitmap when
233
+ * the trees hold edges but membership came back empty. It is explicitly
234
+ * NOT part of readiness — `isReady()` is already `true` from the SSTable
235
+ * loads above, so a slow membership walk can never make a graph with
236
+ * queryable edges report "not ready".
237
+ */
238
+ async openGraphLsmEngine() {
239
+ // Load manifests and SSTables for all 4 trees
240
+ const treeNames = this.native.treeNames();
241
+ for (const treeName of treeNames) {
242
+ await this.loadTreeFromStorage(treeName);
243
+ }
244
+ // Defensive check: if LSM-trees have data but verbIdSet is empty, populate.
245
+ // This is the deferred membership fast-follow — it does NOT gate isReady().
246
+ const sourceSize = this.native.size();
247
+ if (sourceSize > 0 && this.native.verbIdCount() === 0) {
248
+ prodLog.warn(`GraphAdjacencyIndex: LSM-trees have ${sourceSize} relationships but verbIdSet is empty. ` +
249
+ `Triggering auto-rebuild to restore consistency.`);
250
+ await this.populateVerbIdSetFromStorage();
251
+ }
252
+ }
253
+ async populateVerbIdSetFromStorage() {
254
+ prodLog.info('GraphAdjacencyIndex: Populating verbIdSet from storage...');
255
+ const startTime = Date.now();
256
+ let hasMore = true;
257
+ let cursor = undefined;
258
+ let count = 0;
259
+ while (hasMore) {
260
+ const result = await this.storage.getVerbs({
261
+ pagination: { limit: 10000, cursor }
262
+ });
263
+ for (const verb of result.items) {
264
+ // Cor 3.0 Piece D: trackVerbIdByIndex was deleted along with
265
+ // the other strict-index variants. trackVerbId takes the verb-
266
+ // type as a string; unknown strings fall back to RelatedTo —
267
+ // acceptable on the rebuild slow path because the membership
268
+ // bitmap converges to the same set either way.
269
+ this.native.trackVerbId(verb.id, verb.verb);
270
+ count++;
271
+ }
272
+ hasMore = result.hasMore;
273
+ cursor = result.nextCursor;
274
+ }
275
+ const elapsed = Date.now() - startTime;
276
+ prodLog.info(`GraphAdjacencyIndex: Populated verbIdSet with ${count} verb IDs in ${elapsed}ms`);
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // Core graph operations
280
+ // ---------------------------------------------------------------------------
281
+ async getNeighbors(id, optionsOrDirection) {
282
+ await this.ensureInitialized();
283
+ const options = typeof optionsOrDirection === 'string'
284
+ ? { direction: optionsOrDirection }
285
+ : (optionsOrDirection || {});
286
+ const startTime = performance.now();
287
+ const direction = options.direction || 'both';
288
+ const limit = options.limit ?? null;
289
+ const offset = options.offset ?? null;
290
+ const result = this.native.getNeighbors(id, direction, limit, offset);
291
+ const elapsed = performance.now() - startTime;
292
+ if (elapsed > 5.0) {
293
+ prodLog.warn(`GraphAdjacencyIndex: Slow neighbor lookup for ${id}: ${elapsed.toFixed(2)}ms`);
294
+ }
295
+ return result;
296
+ }
297
+ async getVerbIdsBySource(sourceInt, options) {
298
+ await this.ensureInitialized();
299
+ const startTime = performance.now();
300
+ const limit = options?.limit ?? null;
301
+ const offset = options?.offset ?? null;
302
+ const result = this.native.getVerbIdsBySource(sourceInt, limit, offset);
303
+ const elapsed = performance.now() - startTime;
304
+ if (elapsed > 5.0) {
305
+ prodLog.warn(`GraphAdjacencyIndex: Slow getVerbIdsBySource for ${sourceInt}: ${elapsed.toFixed(2)}ms`);
306
+ }
307
+ return result;
308
+ }
309
+ async getVerbIdsByTarget(targetInt, options) {
310
+ await this.ensureInitialized();
311
+ const startTime = performance.now();
312
+ const limit = options?.limit ?? null;
313
+ const offset = options?.offset ?? null;
314
+ const result = this.native.getVerbIdsByTarget(targetInt, limit, offset);
315
+ const elapsed = performance.now() - startTime;
316
+ if (elapsed > 5.0) {
317
+ prodLog.warn(`GraphAdjacencyIndex: Slow getVerbIdsByTarget for ${targetInt}: ${elapsed.toFixed(2)}ms`);
318
+ }
319
+ return result;
320
+ }
321
+ async getVerbCached(verbId) {
322
+ const cacheKey = `graph:verb:${verbId}`;
323
+ const verb = await this.unifiedCache.get(cacheKey, async () => {
324
+ const loadedVerb = await this.storage.getVerb(verbId);
325
+ if (loadedVerb) {
326
+ this.unifiedCache.set(cacheKey, loadedVerb, 'other', 128, 50);
327
+ }
328
+ return loadedVerb;
329
+ });
330
+ return verb;
331
+ }
332
+ async getVerbsBatchCached(verbIds) {
333
+ const results = new Map();
334
+ const uncached = [];
335
+ for (const verbId of verbIds) {
336
+ const cacheKey = `graph:verb:${verbId}`;
337
+ const cached = this.unifiedCache.getSync(cacheKey);
338
+ if (cached) {
339
+ results.set(verbId, cached);
340
+ }
341
+ else {
342
+ uncached.push(verbId);
343
+ }
344
+ }
345
+ if (uncached.length > 0 && this.storage.getVerbsBatch) {
346
+ const loadedVerbs = await this.storage.getVerbsBatch(uncached);
347
+ for (const [verbId, verb] of loadedVerbs.entries()) {
348
+ const cacheKey = `graph:verb:${verbId}`;
349
+ this.unifiedCache.set(cacheKey, verb, 'other', 128, 50);
350
+ results.set(verbId, verb);
351
+ }
352
+ }
353
+ return results;
354
+ }
355
+ // ---------------------------------------------------------------------------
356
+ // Mutation operations
357
+ // ---------------------------------------------------------------------------
358
+ /**
359
+ * Add a verb to the graph. Cor 3.0 Piece D requires the brain's
360
+ * canonical `(sourceInt, targetInt)` entity-int pair as explicit
361
+ * parameters — brainy 8.0 resolves these via its `BinaryIdMapper`
362
+ * before calling. The `verb` object provides the verb-ID, verb-type,
363
+ * and optional subtype; entity UUIDs on the verb (`verb.sourceId`,
364
+ * `verb.targetId`) are no longer used by cor.
365
+ */
366
+ /**
367
+ * Index a new verb (relationship). Returns the verb's interned
368
+ * `u64` integer ID so brainy 8.0's `verb_int ↔ verb_id_string`
369
+ * sidecar map populates without a follow-up
370
+ * `getVerbIntForString` FFI roundtrip — one boundary crossing
371
+ * saved per add at billion-scale ingest (PLATFORM-HANDOFF
372
+ * BRAINY-8.0-RENAME-COORDINATION A.3).
373
+ *
374
+ * The wrapper's previous brainy 7.x signature was
375
+ * `Promise<void>`; brainy 7.x callers that ignore the new
376
+ * return value continue to work unchanged.
377
+ */
378
+ async addVerb(verb, sourceInt, targetInt, generation) {
379
+ await this.ensureInitialized();
380
+ const startTime = performance.now();
381
+ const verbTypeIndex = resolveVerbTypeIndex(verb.verb);
382
+ // Native subtype param is `String` with '' = "no subtype" (the empty-string
383
+ // escape; graph_adjacency.rs guards on `!subtype.is_empty()`). Passing JS
384
+ // null throws "Failed to convert Null into String" at the napi boundary, so
385
+ // coalesce absent subtype to '' here.
386
+ const subtype = verb.subtype ?? '';
387
+ const result = this.native.addVerbWithEndpointsByIndex(verb.id, sourceInt, targetInt, verbTypeIndex, subtype, generation);
388
+ // Flush any trees that hit their threshold
389
+ const flushPromises = [];
390
+ if (result.needsFlushSource) {
391
+ flushPromises.push(this.flushTree('source'));
392
+ }
393
+ if (result.needsFlushTarget) {
394
+ flushPromises.push(this.flushTree('target'));
395
+ }
396
+ if (result.needsFlushVerbsSource) {
397
+ flushPromises.push(this.flushTree('verbs-source'));
398
+ }
399
+ if (result.needsFlushVerbsTarget) {
400
+ flushPromises.push(this.flushTree('verbs-target'));
401
+ }
402
+ if (flushPromises.length > 0) {
403
+ await Promise.all(flushPromises);
404
+ }
405
+ const elapsed = performance.now() - startTime;
406
+ this.totalRelationshipsIndexed++;
407
+ if (elapsed > 10.0) {
408
+ prodLog.warn(`GraphAdjacencyIndex: Slow addVerb for ${verb.id}: ${elapsed.toFixed(2)}ms`);
409
+ }
410
+ return result.verbInt;
411
+ }
412
+ async removeVerb(verbId, generation) {
413
+ await this.ensureInitialized();
414
+ // Load verb to get type + subtype info
415
+ const verb = await this.getVerbCached(verbId);
416
+ if (!verb)
417
+ return;
418
+ const startTime = performance.now();
419
+ const verbTypeIndex = resolveVerbTypeIndex(verb.verb);
420
+ // Native subtype param is `String` with '' = "no subtype" (the empty-string
421
+ // escape; graph_adjacency.rs guards on `!subtype.is_empty()`). Passing JS
422
+ // null throws "Failed to convert Null into String" at the napi boundary, so
423
+ // coalesce absent subtype to '' here.
424
+ const subtype = verb.subtype ?? '';
425
+ this.native.removeVerb(verbId, verbTypeIndex, subtype, generation);
426
+ const elapsed = performance.now() - startTime;
427
+ if (elapsed > 5.0) {
428
+ prodLog.warn(`GraphAdjacencyIndex: Slow removeVerb for ${verbId}: ${elapsed.toFixed(2)}ms`);
429
+ }
430
+ }
431
+ // ---------------------------------------------------------------------------
432
+ // Size, counts, stats
433
+ // ---------------------------------------------------------------------------
434
+ size() {
435
+ return this.native.size();
436
+ }
437
+ getRelationshipCountByType(type) {
438
+ return this.native.getRelationshipCountByType(type);
439
+ }
440
+ getTotalRelationshipCount() {
441
+ return this.native.verbIdCount();
442
+ }
443
+ getAllRelationshipCounts() {
444
+ const json = this.native.getAllRelationshipCountsJson();
445
+ const obj = JSON.parse(json);
446
+ return new Map(Object.entries(obj));
447
+ }
448
+ /**
449
+ * **Batch verb-int reverse resolver** — brainy 8.0
450
+ * `GraphIndexProvider.verbIntsToIds` contract (handoff Section L.7).
451
+ *
452
+ * Given a batch of verb-int BigInts, return the canonical UUID
453
+ * string for each — `null` for any int that has no entry in
454
+ * cor's persistent reverse map.
455
+ *
456
+ * Cor's namespace is the single source of truth for the
457
+ * `verb-int ↔ verb-id` mapping (the persisted `verb-int-to-uuid.bin`
458
+ * is direct-indexed for O(1) random reads). Brainy keeps only a
459
+ * bounded in-memory LRU warm cache fed by `addVerb` returns and
460
+ * resolver hits — no full reverse map on the JS side.
461
+ *
462
+ * The identity-fingerprint design (handoff L.7) makes this
463
+ * lossless: brainy's `relate()` validates verb-ids are canonical
464
+ * UUIDs, cor stores the raw 16 UUID bytes, and `verbIntsToIds`
465
+ * formats them back to the canonical lowercase 8-4-4-4-12 form.
466
+ */
467
+ async verbIntsToIds(verbInts) {
468
+ if (verbInts.length === 0)
469
+ return [];
470
+ const raw = this.native.verbIntsToUuids(verbInts);
471
+ // napi-rs renders `Option<String>` as `string | undefined | null`
472
+ // on the .d.ts surface; normalise to `string | null` for the
473
+ // GraphIndexProvider contract (brainy 8.0 declares the
474
+ // null-or-string return shape strictly).
475
+ return raw.map((v) => v ?? null);
476
+ }
477
+ getRelationshipStats() {
478
+ const json = this.native.getRelationshipStatsJson();
479
+ return JSON.parse(json);
480
+ }
481
+ getStats() {
482
+ const stats = this.native.getStats();
483
+ return {
484
+ totalRelationships: stats.totalRelationships,
485
+ sourceNodes: stats.sourceSstableCount,
486
+ targetNodes: stats.targetSstableCount,
487
+ memoryUsage: stats.sourceMemTableMemory + stats.targetMemTableMemory + (stats.verbIdCount * 8),
488
+ lastRebuild: this.rebuildStartTime,
489
+ rebuildTime: this.isRebuilding ? Date.now() - this.rebuildStartTime : 0,
490
+ };
491
+ }
492
+ isHealthy() {
493
+ if (!this.initialized) {
494
+ return false;
495
+ }
496
+ return !this.isRebuilding && this.native.isHealthy();
497
+ }
498
+ /**
499
+ * Whether the graph's edges are loaded and queryable WITHOUT a rebuild —
500
+ * brainy 8.0's rebuild gate (#36). Delegates straight to the native
501
+ * edges-ready latch (set on SSTable cold-load, a non-empty manifest, or a
502
+ * memtable flush) ORed with a live non-empty memtable. Synchronous (no
503
+ * await) and **never gated on the verb-id membership walk**, so the
504
+ * deferred O(N) membership fast-follow can never depress readiness for a
505
+ * graph whose edges are already mapped in.
506
+ */
507
+ isReady() {
508
+ return this.native.isReady();
509
+ }
510
+ /**
511
+ * @description **#18 migration lock — feature-detected by Brainy.** `true` while
512
+ * the brain is undergoing (or has failed) the coordinated 7.x → 8.0 rebuild, so
513
+ * Brainy holds reads+writes behind the lock (and, in rc.9, `verifyGraphAdjacencyLive`
514
+ * short-circuits to `'live'` rather than firing its own rebuild-on-read).
515
+ * Delegates to the shared {@link MigrationCoordinator}; `false` when none is wired.
516
+ * @returns `true` when the brain must not be read from or written to.
517
+ */
518
+ isMigrating() {
519
+ return this.migrationCoordinatorGetter?.()?.isMigrating() ?? false;
520
+ }
521
+ /**
522
+ * @description **#18 migration lock — structured progress** Brainy relays into
523
+ * `getIndexStatus().migration`. `null` unless a migration is in flight.
524
+ * @returns The current {@link MigrationStatus}, or `null`.
525
+ */
526
+ migrationStatus() {
527
+ return this.migrationCoordinatorGetter?.()?.migrationStatus() ?? null;
528
+ }
529
+ // ---------------------------------------------------------------------------
530
+ // Rebuild
531
+ // ---------------------------------------------------------------------------
532
+ /**
533
+ * Rebuild the graph adjacency from brainy's persisted verbs — used after a
534
+ * restore/migration that wiped the native subdirs (#68).
535
+ *
536
+ * **Full adjacency reconstruction** when a shared entity-id mapper resolver is
537
+ * wired (the metadata index's mapper — the SAME one brainy resolves verb
538
+ * endpoints through at write time, `metadataIndex.getIdMapper().getOrAssign`).
539
+ * For each persisted verb this resolves `sourceId`/`targetId` → entity ints via
540
+ * that mapper and replays `addVerbWithEndpointsByIndex`, rebuilding all four
541
+ * u64-keyed LSM trees exactly as the live write path does (which also
542
+ * repopulates the membership bitmap + per-type counts — so `trackVerbId` is NOT
543
+ * also called for a reconstructed edge, or membership/counts would double). Brainy
544
+ * 8.0 `restore()` awaits `metadataIndex.getIdMapper().rebuild()` BEFORE the
545
+ * `Promise.all` that runs this, so the mapper is fully populated with the
546
+ * snapshot's assignments before endpoints are resolved here.
547
+ *
548
+ * Cursor-paginated (#74) — O(batch) RAM at any edge count; memtable flush
549
+ * thresholds are respected mid-walk so RAM stays bounded at billion-edge scale.
550
+ * Endpoints whose UUID does not resolve in the mapper (an entity that is a graph
551
+ * node but was not walked as a noun) are tracked for membership, counted, and
552
+ * warned — never silently dropped. Restored edges reconstruct at the floor
553
+ * generation (0): present-state is exact; historical as-of-g for pre-restore
554
+ * edges is not reconstructable from canonical records (the same limit the
555
+ * metadata + vector rebuilds carry).
556
+ *
557
+ * Without a mapper resolver (cor used without a metadata index), it degrades to
558
+ * the membership-only pass (bitmap + per-type counts via `trackVerbId`) and logs
559
+ * that adjacency was not reconstructed — the on-disk `.sst` trees loaded in
560
+ * {@link ensureInitialized} remain the adjacency source in that case.
561
+ */
562
+ async rebuild() {
563
+ await this.ensureInitialized();
564
+ if (this.isRebuilding) {
565
+ prodLog.warn('GraphAdjacencyIndex: Rebuild already in progress');
566
+ return;
567
+ }
568
+ this.isRebuilding = true;
569
+ this.rebuildStartTime = Date.now();
570
+ try {
571
+ const mapper = this.mapperResolver?.();
572
+ const reconstructAdjacency = mapper !== undefined;
573
+ prodLog.info(reconstructAdjacency
574
+ ? 'GraphAdjacencyIndex: Starting rebuild (full adjacency — resolving endpoints via shared mapper)...'
575
+ : 'GraphAdjacencyIndex: Starting rebuild (membership-only — no shared mapper wired)...');
576
+ this.native.clearVerbTracking();
577
+ // Restored edges reconstruct present-state at the floor generation; the
578
+ // canonical verb records carry no per-edge history, so as-of-g for
579
+ // pre-restore edges is not reconstructable (same limit as metadata/vector
580
+ // rebuilds). Current (non-as-of) reads see them — a single floor-gen entry
581
+ // is the latest in each verb's version chain.
582
+ const REBUILD_GENERATION = 0n;
583
+ // Cursor-paginate for EVERY storage type — O(batch) RAM, complete at any
584
+ // scale; never load-all (the OOM / silent-10M-truncation trap). (#74)
585
+ let totalVerbs = 0;
586
+ let edgesRebuilt = 0;
587
+ let unresolvedEndpoints = 0;
588
+ let hasMore = true;
589
+ let cursor = undefined;
590
+ const batchSize = 10000;
591
+ while (hasMore) {
592
+ const result = await this.storage.getVerbs({ pagination: { limit: batchSize, cursor } });
593
+ for (const verb of result.items) {
594
+ totalVerbs++;
595
+ const verbType = verb.verb ?? 'relatedTo';
596
+ if (!reconstructAdjacency) {
597
+ this.native.trackVerbId(verb.id, verbType);
598
+ continue;
599
+ }
600
+ const sourceId = verb.sourceId;
601
+ const targetId = verb.targetId;
602
+ const sourceInt = sourceId !== undefined ? mapper.getInt(sourceId) : undefined;
603
+ const targetInt = targetId !== undefined ? mapper.getInt(targetId) : undefined;
604
+ if (sourceInt === undefined || targetInt === undefined) {
605
+ // Endpoint not in the mapper — still record membership so the verb
606
+ // isn't lost; its adjacency can't be rebuilt without the int.
607
+ this.native.trackVerbId(verb.id, verbType);
608
+ unresolvedEndpoints++;
609
+ continue;
610
+ }
611
+ const verbTypeIndex = resolveVerbTypeIndex(verbType);
612
+ // Native subtype param is `String` with '' = "no subtype" (the
613
+ // empty-string escape; graph_adjacency.rs guards on
614
+ // `!subtype.is_empty()`). JS null throws "Null into String" at the
615
+ // napi boundary, so coalesce absent subtype to ''.
616
+ const subtype = verb.subtype ?? '';
617
+ const addResult = this.native.addVerbWithEndpointsByIndex(verb.id, BigInt(sourceInt), BigInt(targetInt), verbTypeIndex, subtype, REBUILD_GENERATION);
618
+ edgesRebuilt++;
619
+ // Respect memtable flush thresholds mid-walk so RAM stays bounded.
620
+ const flushPromises = [];
621
+ if (addResult.needsFlushSource)
622
+ flushPromises.push(this.flushTree('source'));
623
+ if (addResult.needsFlushTarget)
624
+ flushPromises.push(this.flushTree('target'));
625
+ if (addResult.needsFlushVerbsSource)
626
+ flushPromises.push(this.flushTree('verbs-source'));
627
+ if (addResult.needsFlushVerbsTarget)
628
+ flushPromises.push(this.flushTree('verbs-target'));
629
+ if (flushPromises.length > 0)
630
+ await Promise.all(flushPromises);
631
+ }
632
+ hasMore = result.hasMore;
633
+ cursor = result.nextCursor;
634
+ }
635
+ this.totalRelationshipsIndexed = totalVerbs;
636
+ if (reconstructAdjacency) {
637
+ // Persist partially-filled memtables from the final batch.
638
+ await this.flush();
639
+ if (unresolvedEndpoints > 0) {
640
+ prodLog.warn(`GraphAdjacencyIndex: ${unresolvedEndpoints.toLocaleString()} verb endpoint(s) did not ` +
641
+ `resolve in the shared mapper and kept membership-only (entity is a graph node but was ` +
642
+ `not walked as a noun). ${edgesRebuilt.toLocaleString()} edges fully rebuilt.`);
643
+ }
644
+ }
645
+ const rebuildTime = Date.now() - this.rebuildStartTime;
646
+ const memoryUsage = this.calculateMemoryUsage();
647
+ prodLog.info(`GraphAdjacencyIndex: Rebuild complete in ${rebuildTime}ms`);
648
+ prodLog.info(` - Total verbs tracked: ${totalVerbs.toLocaleString()}`);
649
+ if (reconstructAdjacency) {
650
+ prodLog.info(` - Adjacency edges rebuilt: ${edgesRebuilt.toLocaleString()} (unresolved endpoints: ${unresolvedEndpoints.toLocaleString()})`);
651
+ }
652
+ prodLog.info(` - Memory usage: ${(memoryUsage / 1024 / 1024).toFixed(1)}MB`);
653
+ prodLog.info(` - Stats:`, this.native.getStats());
654
+ }
655
+ finally {
656
+ this.isRebuilding = false;
657
+ }
658
+ }
659
+ // ---------------------------------------------------------------------------
660
+ // Flush and close
661
+ // ---------------------------------------------------------------------------
662
+ async flush() {
663
+ if (!this.initialized) {
664
+ return;
665
+ }
666
+ const startTime = Date.now();
667
+ const treeNames = this.native.treeNames();
668
+ const flushPromises = treeNames
669
+ .filter(name => !this.native.memTableIsEmpty(name))
670
+ .map(name => this.flushTree(name));
671
+ if (flushPromises.length > 0) {
672
+ await Promise.all(flushPromises);
673
+ }
674
+ const elapsed = Date.now() - startTime;
675
+ prodLog.debug(`GraphAdjacencyIndex: Flush completed in ${elapsed}ms`);
676
+ }
677
+ async close() {
678
+ if (this.flushTimer) {
679
+ clearInterval(this.flushTimer);
680
+ this.flushTimer = undefined;
681
+ }
682
+ if (this.initialized) {
683
+ // Flush all trees that have pending data
684
+ await this.flush();
685
+ }
686
+ prodLog.info('GraphAdjacencyIndex: Shutdown complete');
687
+ }
688
+ // ---------------------------------------------------------------------------
689
+ // Private: Per-tree storage I/O (adapter-controlled)
690
+ // ---------------------------------------------------------------------------
691
+ /** Blob key for an SSTable: "graph-lsm/{treeName}/{sstableId}" */
692
+ blobKey(treeName, sstableId) {
693
+ return `graph-lsm/${treeName}/${sstableId}`;
694
+ }
695
+ async loadTreeFromStorage(treeName) {
696
+ const prefix = TREE_PREFIXES[treeName];
697
+ if (!prefix)
698
+ return;
699
+ try {
700
+ const metadata = await this.storage.getMetadata(`${prefix}-manifest`);
701
+ if (metadata && metadata.data) {
702
+ const data = metadata.data;
703
+ // Pass the FULL persisted manifest to the native loader, NOT a hand-picked
704
+ // subset. `saveTreeManifest` persists `JSON.parse(getManifestJson(tree))`
705
+ // verbatim, so `data` already IS the native manifest shape. The previous
706
+ // rebuild kept only {sstables, lastCompaction, totalRelationships} and
707
+ // DROPPED the other fields `getManifestSstableIds` reads — so on a cold
708
+ // reopen the native loader saw ZERO sstable ids, loaded nothing, and the
709
+ // adjacency came back empty (manifest count present, getNeighbors → []).
710
+ // This is the 2.7.6 Venue-P1 cold-load bug; 3.0 had the identical defect.
711
+ const manifestJson = JSON.stringify({
712
+ ...data,
713
+ sstables: data.sstables || {},
714
+ lastCompaction: data.lastCompaction || Date.now(),
715
+ totalRelationships: data.totalRelationships || 0,
716
+ });
717
+ this.native.loadManifestJson(treeName, manifestJson);
718
+ await this.loadTreeSSTables(treeName, prefix);
719
+ }
720
+ }
721
+ catch (loadErr) {
722
+ // Previously a silent `catch {}` ("no manifest, starting fresh") that ALSO
723
+ // swallowed a real cold-load failure, leaving the count loaded and the
724
+ // adjacency empty. Surface it so a genuine failure is never silent.
725
+ prodLog.warn(`GraphAdjacencyIndex: cold-load failed for ${treeName} — adjacency may be incomplete`, loadErr);
726
+ }
727
+ }
728
+ async loadTreeSSTables(treeName, prefix) {
729
+ const ids = this.native.getManifestSstableIds(treeName);
730
+ let mmapLoaded = 0;
731
+ let binaryLoaded = 0;
732
+ const loadPromises = ids.map(async (sstableId) => {
733
+ try {
734
+ const level = this.native.getManifestSstableLevel(treeName, sstableId);
735
+ if (level === null || level === undefined) {
736
+ return;
737
+ }
738
+ const key = this.blobKey(treeName, sstableId);
739
+ // Tier 1: Try mmap path (getBinaryBlobPath → loadSstableFromFile)
740
+ if (this.hasMmapPaths) {
741
+ const filePath = this.storage.getBinaryBlobPath(key);
742
+ if (filePath) {
743
+ try {
744
+ this.native.loadSstableFromFile(treeName, sstableId, level, filePath);
745
+ mmapLoaded++;
746
+ return;
747
+ }
748
+ catch (mmapErr) {
749
+ // On a cold reopen the file exists, so a throw here is a real
750
+ // failure (not "first run") — surface it, then fall through to the
751
+ // binary tier which can still serve it. Never silent (2.7.6 parity).
752
+ prodLog.warn(`GraphAdjacencyIndex: mmap SSTable load failed for ${treeName}/${sstableId} @ ${filePath} — trying binary tier`, mmapErr);
753
+ }
754
+ }
755
+ }
756
+ // Tier 2: Try binary blob (loadBinaryBlob → loadSstableFromBinary).
757
+ // Cor 3.0 Piece D dropped the legacy base64-in-JSON tier
758
+ // because the napi-side `loadSstable` (msgpack) is gone — the
759
+ // u64-keyed LSM core only ships the binary `.sst` format. Any
760
+ // brain persisted under the old shape needs the offline
761
+ // migrator (Piece E) before it can be opened.
762
+ if (this.hasBinaryBlobs) {
763
+ const blobData = await this.storage.loadBinaryBlob(key);
764
+ if (blobData) {
765
+ this.native.loadSstableFromBinary(treeName, sstableId, level, blobData);
766
+ binaryLoaded++;
767
+ return;
768
+ }
769
+ }
770
+ // No tier matched — log and skip.
771
+ prodLog.warn(`GraphAdjacencyIndex: SSTable ${sstableId} for ${treeName} found in manifest but no readable backing exists (skipped)`);
772
+ }
773
+ catch (error) {
774
+ prodLog.warn(`GraphAdjacencyIndex: Failed to load SSTable ${sstableId} for ${treeName}`, error);
775
+ }
776
+ });
777
+ await Promise.all(loadPromises);
778
+ const parts = [];
779
+ if (mmapLoaded > 0)
780
+ parts.push(`${mmapLoaded} mmap`);
781
+ if (binaryLoaded > 0)
782
+ parts.push(`${binaryLoaded} binary`);
783
+ const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
784
+ prodLog.info(`GraphAdjacencyIndex: Loaded ${ids.length} SSTables for ${treeName}${detail}`);
785
+ }
786
+ async flushTree(treeName) {
787
+ if (this.native.memTableIsEmpty(treeName)) {
788
+ return;
789
+ }
790
+ const prefix = TREE_PREFIXES[treeName];
791
+ if (!prefix)
792
+ return;
793
+ const startTime = Date.now();
794
+ try {
795
+ // Binary blob flush (adapter-controlled): Rust serializes → adapter writes
796
+ if (this.hasBinaryBlobs) {
797
+ const result = this.native.flushTreeToBinary(treeName);
798
+ if (!result)
799
+ return;
800
+ const key = this.blobKey(treeName, result.sstableId);
801
+ await this.storage.saveBinaryBlob(key, Buffer.from(result.data));
802
+ // If adapter supports mmap, upgrade InMemory → Mapped
803
+ if (this.hasMmapPaths) {
804
+ const filePath = this.storage.getBinaryBlobPath(key);
805
+ if (filePath) {
806
+ try {
807
+ this.native.registerMmapSstable(treeName, result.sstableId, result.level, filePath);
808
+ }
809
+ catch {
810
+ // Non-fatal: InMemory variant still works
811
+ }
812
+ }
813
+ }
814
+ await this.saveTreeManifest(treeName, prefix);
815
+ const elapsed = Date.now() - startTime;
816
+ prodLog.info(`GraphAdjacencyIndex: Flushed ${treeName} in ${elapsed}ms (${result.entryCount} entries, ${result.valueCount} values, binary blob)`);
817
+ if (this.native.needsCompaction(treeName, 0)) {
818
+ setImmediate(() => this.compactTree(treeName, 0));
819
+ }
820
+ return;
821
+ }
822
+ // Cor 3.0 Piece D dropped the legacy base64-in-JSON flush
823
+ // path — the napi-side `flushTree` (msgpack) is gone. The only
824
+ // remaining flush path is the binary blob branch above which
825
+ // requires an adapter exposing `saveBinaryBlob`.
826
+ prodLog.warn(`GraphAdjacencyIndex: Skipping flush for ${treeName}: storage adapter does not expose saveBinaryBlob (Piece D requires binary blob I/O)`);
827
+ }
828
+ catch (error) {
829
+ prodLog.error(`GraphAdjacencyIndex: Failed to flush ${treeName}`, error);
830
+ throw error;
831
+ }
832
+ }
833
+ async compactTree(treeName, level) {
834
+ if (this.isCompacting[treeName]) {
835
+ return;
836
+ }
837
+ this.isCompacting[treeName] = true;
838
+ const prefix = TREE_PREFIXES[treeName];
839
+ if (!prefix)
840
+ return;
841
+ const startTime = Date.now();
842
+ try {
843
+ // Binary blob compaction (adapter-controlled).
844
+ //
845
+ // **Piece 6 manifest ordering invariant**: durability requires
846
+ // 1. write new blob (fully persisted)
847
+ // 2. update manifest (the durable source-of-truth that
848
+ // references the new blob and no longer references the
849
+ // old blobs)
850
+ // 3. THEN delete old blobs (orphan cleanup, only safe to do
851
+ // AFTER the manifest no longer references them)
852
+ //
853
+ // The earlier shape deleted old blobs BEFORE updating the
854
+ // manifest. A crash between those two steps left the
855
+ // manifest pointing at blobs that had been physically removed,
856
+ // so reopening the brain failed with "missing SSTable file".
857
+ // Putting the manifest update before the delete makes the
858
+ // crash-between-steps state recoverable: the manifest still
859
+ // references the just-deleted (or pending-deletion) blobs,
860
+ // both of which exist on disk during that window.
861
+ if (this.hasBinaryBlobs) {
862
+ const result = this.native.compactTreeToBinary(treeName, level);
863
+ if (!result)
864
+ return;
865
+ prodLog.info(`GraphAdjacencyIndex: Compacting ${treeName} L${level} → L${result.newLevel} (binary blob)`);
866
+ // 1. Save new merged SSTable via adapter — must complete
867
+ // BEFORE any reference (manifest) is updated.
868
+ const newKey = this.blobKey(treeName, result.newSstableId);
869
+ await this.storage.saveBinaryBlob(newKey, Buffer.from(result.data));
870
+ // If adapter supports mmap, upgrade InMemory → Mapped.
871
+ // Best-effort; the InMemory variant still works.
872
+ if (this.hasMmapPaths) {
873
+ const filePath = this.storage.getBinaryBlobPath(newKey);
874
+ if (filePath) {
875
+ try {
876
+ this.native.registerMmapSstable(treeName, result.newSstableId, result.newLevel, filePath);
877
+ }
878
+ catch {
879
+ // Non-fatal: InMemory variant still works
880
+ }
881
+ }
882
+ }
883
+ // 2. Update the manifest. After this point the durable
884
+ // source-of-truth references the new blob and no longer
885
+ // references the old blobs — so a crash before step 3
886
+ // just leaves orphans, never a missing-blob error.
887
+ await this.saveTreeManifest(treeName, prefix);
888
+ // 3. Delete old blobs (best-effort orphan cleanup AFTER the
889
+ // manifest no longer references them). Failures here are
890
+ // harmless — the worst outcome is leaked disk space until
891
+ // a future operator-level GC sweep.
892
+ for (const oldId of result.oldIds) {
893
+ const oldBlobKey = this.blobKey(treeName, oldId);
894
+ this.storage.deleteBinaryBlob(oldBlobKey).catch(() => { });
895
+ }
896
+ const elapsed = Date.now() - startTime;
897
+ prodLog.info(`GraphAdjacencyIndex: Compaction ${treeName} L${level} → L${result.newLevel} complete in ${elapsed}ms`);
898
+ if (result.newLevel < 6 && this.native.needsCompaction(treeName, result.newLevel)) {
899
+ setImmediate(() => this.compactTree(treeName, result.newLevel));
900
+ }
901
+ return;
902
+ }
903
+ // Cor 3.0 Piece D dropped the legacy base64-in-JSON compaction
904
+ // path — the napi-side `compactTree` (msgpack) is gone. The only
905
+ // remaining path is the binary blob branch above which requires
906
+ // an adapter exposing `saveBinaryBlob`.
907
+ prodLog.warn(`GraphAdjacencyIndex: Skipping compaction for ${treeName} L${level}: storage adapter does not expose saveBinaryBlob (Piece D requires binary blob I/O)`);
908
+ }
909
+ catch (error) {
910
+ prodLog.error(`GraphAdjacencyIndex: Compaction failed for ${treeName} L${level}`, error);
911
+ }
912
+ finally {
913
+ this.isCompacting[treeName] = false;
914
+ }
915
+ }
916
+ async saveTreeManifest(treeName, prefix) {
917
+ try {
918
+ const manifestJson = this.native.getManifestJson(treeName);
919
+ const manifestData = JSON.parse(manifestJson);
920
+ await this.storage.saveMetadata(`${prefix}-manifest`, {
921
+ noun: 'thing',
922
+ data: manifestData,
923
+ });
924
+ }
925
+ catch (error) {
926
+ prodLog.error(`GraphAdjacencyIndex: Failed to save manifest for ${treeName}`, error);
927
+ throw error;
928
+ }
929
+ }
930
+ startAutoFlush() {
931
+ this.flushTimer = setInterval(async () => {
932
+ await this.flush();
933
+ }, this.config.flushInterval);
934
+ }
935
+ calculateMemoryUsage() {
936
+ const stats = this.native.getStats();
937
+ let bytes = 0;
938
+ bytes += stats.sourceMemTableMemory;
939
+ bytes += stats.targetMemTableMemory;
940
+ bytes += stats.verbIdCount * 8;
941
+ return bytes;
942
+ }
943
+ }
944
+ //# sourceMappingURL=NativeGraphAdjacencyIndex.js.map