@git-stunts/git-warp 11.5.1 → 12.0.0

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 (46) hide show
  1. package/README.md +142 -10
  2. package/bin/cli/commands/registry.js +4 -0
  3. package/bin/cli/commands/reindex.js +41 -0
  4. package/bin/cli/commands/verify-index.js +59 -0
  5. package/bin/cli/infrastructure.js +7 -2
  6. package/bin/cli/schemas.js +19 -0
  7. package/bin/cli/types.js +2 -0
  8. package/index.d.ts +49 -12
  9. package/package.json +2 -2
  10. package/src/domain/WarpGraph.js +40 -0
  11. package/src/domain/errors/ShardIdOverflowError.js +28 -0
  12. package/src/domain/errors/index.js +1 -0
  13. package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
  14. package/src/domain/services/BitmapNeighborProvider.js +178 -0
  15. package/src/domain/services/CheckpointMessageCodec.js +3 -3
  16. package/src/domain/services/CheckpointService.js +77 -12
  17. package/src/domain/services/GraphTraversal.js +1239 -0
  18. package/src/domain/services/IncrementalIndexUpdater.js +765 -0
  19. package/src/domain/services/JoinReducer.js +233 -5
  20. package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
  21. package/src/domain/services/LogicalIndexBuildService.js +108 -0
  22. package/src/domain/services/LogicalIndexReader.js +315 -0
  23. package/src/domain/services/LogicalTraversal.js +321 -202
  24. package/src/domain/services/MaterializedViewService.js +379 -0
  25. package/src/domain/services/ObserverView.js +138 -47
  26. package/src/domain/services/PatchBuilderV2.js +3 -3
  27. package/src/domain/services/PropertyIndexBuilder.js +64 -0
  28. package/src/domain/services/PropertyIndexReader.js +111 -0
  29. package/src/domain/services/TemporalQuery.js +128 -14
  30. package/src/domain/types/PatchDiff.js +90 -0
  31. package/src/domain/types/WarpTypesV2.js +4 -4
  32. package/src/domain/utils/MinHeap.js +45 -17
  33. package/src/domain/utils/canonicalCbor.js +36 -0
  34. package/src/domain/utils/fnv1a.js +20 -0
  35. package/src/domain/utils/roaring.js +14 -3
  36. package/src/domain/utils/shardKey.js +40 -0
  37. package/src/domain/utils/toBytes.js +17 -0
  38. package/src/domain/warp/_wiredMethods.d.ts +7 -1
  39. package/src/domain/warp/checkpoint.methods.js +21 -5
  40. package/src/domain/warp/materialize.methods.js +17 -5
  41. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  42. package/src/domain/warp/query.methods.js +78 -12
  43. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  44. package/src/ports/BlobPort.js +1 -1
  45. package/src/ports/NeighborProviderPort.js +59 -0
  46. package/src/ports/SeekCachePort.js +4 -3
@@ -0,0 +1,765 @@
1
+ /**
2
+ * Stateless service that computes dirty shard buffers from a PatchDiff.
3
+ *
4
+ * Given a diff of alive-ness transitions + a shard loader for the existing
5
+ * index tree, produces only the shard buffers that changed. The caller
6
+ * merges them back into the tree via `{ ...existingTree, ...dirtyShards }`.
7
+ *
8
+ * @module domain/services/IncrementalIndexUpdater
9
+ */
10
+
11
+ import defaultCodec from '../utils/defaultCodec.js';
12
+ import computeShardKey from '../utils/shardKey.js';
13
+ import toBytes from '../utils/toBytes.js';
14
+ import { getRoaringBitmap32 } from '../utils/roaring.js';
15
+ import { orsetContains, orsetElements } from '../crdt/ORSet.js';
16
+ import { decodeEdgeKey } from './KeyCodec.js';
17
+ import { ShardIdOverflowError } from '../errors/index.js';
18
+
19
+ /** Maximum local IDs per shard (2^24). */
20
+ const MAX_LOCAL_ID = 1 << 24;
21
+
22
+ /**
23
+ * @typedef {Object} MetaShard
24
+ * @property {Array<[string, number]>} nodeToGlobal
25
+ * @property {number} nextLocalId
26
+ * @property {import('../utils/roaring.js').RoaringBitmapSubset} aliveBitmap
27
+ * @property {Map<number, string>} globalToNode - Reverse lookup: globalId → nodeId (O(1))
28
+ * @property {Map<string, number>} nodeToGlobalMap - Forward lookup: nodeId → globalId (O(1))
29
+ */
30
+
31
+ /**
32
+ * @typedef {Record<string, Record<string, Uint8Array>>} EdgeShardData
33
+ * Keyed by bucket ("all" or labelId string), then by globalId string → serialised bitmap.
34
+ */
35
+
36
+ export default class IncrementalIndexUpdater {
37
+ /**
38
+ * @param {Object} [options]
39
+ * @param {import('../../ports/CodecPort.js').default} [options.codec]
40
+ */
41
+ constructor({ codec } = {}) {
42
+ this._codec = codec || defaultCodec;
43
+ }
44
+
45
+ /**
46
+ * Computes only the dirty shards from a PatchDiff.
47
+ *
48
+ * @param {Object} params
49
+ * @param {import('../types/PatchDiff.js').PatchDiff} params.diff
50
+ * @param {import('./JoinReducer.js').WarpStateV5} params.state
51
+ * @param {(path: string) => Uint8Array|undefined} params.loadShard
52
+ * @returns {Record<string, Uint8Array>} dirty shard buffers (path -> Uint8Array)
53
+ */
54
+ computeDirtyShards({ diff, state, loadShard }) {
55
+ const dirtyKeys = this._collectDirtyShardKeys(diff);
56
+ if (dirtyKeys.size === 0) {
57
+ return {};
58
+ }
59
+
60
+ /** @type {Map<string, MetaShard>} */
61
+ const metaCache = new Map();
62
+ /** @type {Record<string, Uint8Array>} */
63
+ const out = {};
64
+
65
+ const labels = this._loadLabels(loadShard);
66
+ let labelsDirty = false;
67
+
68
+ for (const nodeId of diff.nodesAdded) {
69
+ this._handleNodeAdd(nodeId, metaCache, loadShard);
70
+ }
71
+ for (const nodeId of diff.nodesRemoved) {
72
+ this._handleNodeRemove(nodeId, metaCache, loadShard);
73
+ }
74
+
75
+ /** @type {Map<string, EdgeShardData>} */
76
+ const fwdCache = new Map();
77
+ /** @type {Map<string, EdgeShardData>} */
78
+ const revCache = new Map();
79
+
80
+ // Purge edge bitmaps for removed nodes (dangling edge elimination).
81
+ // An edge whose endpoint is dead must not appear in the index, even
82
+ // if the edge itself is still alive in the ORSet.
83
+ for (const nodeId of diff.nodesRemoved) {
84
+ this._purgeNodeEdges(nodeId, metaCache, fwdCache, revCache, labels, loadShard);
85
+ }
86
+
87
+ // Filter edgesAdded by endpoint alive-ness (matches edgeVisibleV5).
88
+ for (const edge of diff.edgesAdded) {
89
+ if (!orsetContains(state.nodeAlive, edge.from) || !orsetContains(state.nodeAlive, edge.to)) {
90
+ continue;
91
+ }
92
+ labelsDirty = this._ensureLabel(edge.label, labels) || labelsDirty;
93
+ this._handleEdgeAdd(edge, labels, metaCache, fwdCache, revCache, loadShard);
94
+ }
95
+ for (const edge of diff.edgesRemoved) {
96
+ this._handleEdgeRemove(edge, labels, metaCache, fwdCache, revCache, loadShard);
97
+ }
98
+
99
+ // Restore edges for re-added nodes. When a node transitions
100
+ // not-alive -> alive, edges touching it that are alive in the ORSet
101
+ // become visible again. The diff only tracks explicit EdgeAdd ops,
102
+ // not these implicit visibility transitions.
103
+ //
104
+ // Known O(E) worst-case: scans all alive edges. For genuinely new nodes
105
+ // (not re-adds), this scan is unnecessary since they can't have pre-existing
106
+ // edges. _findGlobalId returns undefined for new nodes, so this could be
107
+ // short-circuited — deferred for a future optimization pass.
108
+ if (diff.nodesAdded.length > 0) {
109
+ const addedSet = new Set(diff.nodesAdded);
110
+ const diffEdgeSet = new Set(
111
+ diff.edgesAdded.map((e) => `${e.from}\0${e.to}\0${e.label}`),
112
+ );
113
+ for (const edgeKey of orsetElements(state.edgeAlive)) {
114
+ const { from, to, label } = decodeEdgeKey(edgeKey);
115
+ if (!addedSet.has(from) && !addedSet.has(to)) {
116
+ continue;
117
+ }
118
+ if (!orsetContains(state.nodeAlive, from) || !orsetContains(state.nodeAlive, to)) {
119
+ continue;
120
+ }
121
+ const diffKey = `${from}\0${to}\0${label}`;
122
+ if (diffEdgeSet.has(diffKey)) {
123
+ continue;
124
+ }
125
+ labelsDirty = this._ensureLabel(label, labels) || labelsDirty;
126
+ this._handleEdgeAdd({ from, to, label }, labels, metaCache, fwdCache, revCache, loadShard);
127
+ }
128
+ }
129
+
130
+ this._flushMeta(metaCache, out);
131
+ this._flushEdgeShards(fwdCache, 'fwd', out);
132
+ this._flushEdgeShards(revCache, 'rev', out);
133
+
134
+ if (labelsDirty) {
135
+ out['labels.cbor'] = this._saveLabels(labels);
136
+ }
137
+
138
+ this._handleProps(diff.propsChanged, loadShard, out);
139
+
140
+ return out;
141
+ }
142
+
143
+ // ── Dirty shard key collection ────────────────────────────────────────────
144
+
145
+ /**
146
+ * Collects all shard keys touched by the diff.
147
+ *
148
+ * @param {import('../types/PatchDiff.js').PatchDiff} diff
149
+ * @returns {Set<string>}
150
+ * @private
151
+ */
152
+ _collectDirtyShardKeys(diff) {
153
+ const keys = new Set();
154
+ for (const nid of diff.nodesAdded) {
155
+ keys.add(computeShardKey(nid));
156
+ }
157
+ for (const nid of diff.nodesRemoved) {
158
+ keys.add(computeShardKey(nid));
159
+ }
160
+ for (const e of diff.edgesAdded) {
161
+ keys.add(computeShardKey(e.from));
162
+ keys.add(computeShardKey(e.to));
163
+ }
164
+ for (const e of diff.edgesRemoved) {
165
+ keys.add(computeShardKey(e.from));
166
+ keys.add(computeShardKey(e.to));
167
+ }
168
+ for (const p of diff.propsChanged) {
169
+ keys.add(computeShardKey(p.nodeId));
170
+ }
171
+ return keys;
172
+ }
173
+
174
+ // ── Node operations ───────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Handles a NodeAdd: allocate or reactivate globalId, set alive bit.
178
+ *
179
+ * @param {string} nodeId
180
+ * @param {Map<string, MetaShard>} metaCache
181
+ * @param {(path: string) => Uint8Array|undefined} loadShard
182
+ * @private
183
+ */
184
+ _handleNodeAdd(nodeId, metaCache, loadShard) {
185
+ const sk = computeShardKey(nodeId);
186
+ const meta = this._getOrLoadMeta(sk, metaCache, loadShard);
187
+ const existing = this._findGlobalId(meta, nodeId);
188
+ if (existing !== undefined) {
189
+ meta.aliveBitmap.add(existing);
190
+ return;
191
+ }
192
+ if (meta.nextLocalId >= MAX_LOCAL_ID) {
193
+ throw new ShardIdOverflowError(
194
+ `Shard ${sk} exceeded 2^24 local IDs`,
195
+ { shardKey: sk, nextLocalId: meta.nextLocalId },
196
+ );
197
+ }
198
+ const shardByte = parseInt(sk, 16);
199
+ const globalId = ((shardByte << 24) | meta.nextLocalId) >>> 0;
200
+ meta.nextLocalId++;
201
+ meta.nodeToGlobal.push([nodeId, globalId]);
202
+ meta.globalToNode.set(globalId, nodeId);
203
+ meta.nodeToGlobalMap.set(nodeId, globalId);
204
+ meta.aliveBitmap.add(globalId);
205
+ }
206
+
207
+ /**
208
+ * Handles a NodeRemove: clear alive bit but keep globalId stable.
209
+ *
210
+ * @param {string} nodeId
211
+ * @param {Map<string, MetaShard>} metaCache
212
+ * @param {(path: string) => Uint8Array|undefined} loadShard
213
+ * @private
214
+ */
215
+ _handleNodeRemove(nodeId, metaCache, loadShard) {
216
+ const meta = this._getOrLoadMeta(computeShardKey(nodeId), metaCache, loadShard);
217
+ const gid = this._findGlobalId(meta, nodeId);
218
+ if (gid !== undefined) {
219
+ meta.aliveBitmap.remove(gid);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Purges all edge bitmap entries that reference a removed node.
225
+ *
226
+ * When a node is removed, edges that touch it become invisible (even if the
227
+ * edge itself is still alive in the ORSet). The full rebuild skips these via
228
+ * edgeVisibleV5; the incremental path must explicitly purge them.
229
+ *
230
+ * Scans the alive edges in the ORSet for any that reference the dead node,
231
+ * then removes those entries from the forward and reverse edge bitmaps.
232
+ *
233
+ * @param {string} deadNodeId
234
+ * @param {Map<string, MetaShard>} metaCache
235
+ * @param {Map<string, EdgeShardData>} fwdCache
236
+ * @param {Map<string, EdgeShardData>} revCache
237
+ * @param {Record<string, number>} labels
238
+ * @param {(path: string) => Uint8Array|undefined} loadShard
239
+ * @private
240
+ */
241
+ _purgeNodeEdges(deadNodeId, metaCache, fwdCache, revCache, labels, loadShard) {
242
+ const deadMeta = this._getOrLoadMeta(computeShardKey(deadNodeId), metaCache, loadShard);
243
+ const deadGid = this._findGlobalId(deadMeta, deadNodeId);
244
+ if (deadGid === undefined) {
245
+ return;
246
+ }
247
+
248
+ // Purge the dead node's own fwd/rev shard entries by zeroing out its
249
+ // bitmap rows and clearing its globalId from peer bitmaps.
250
+ const shardKey = computeShardKey(deadNodeId);
251
+ const RoaringBitmap32 = getRoaringBitmap32();
252
+
253
+ // Clear the dead node's own outgoing edges (forward shard)
254
+ const fwdData = this._getOrLoadEdgeShard(fwdCache, 'fwd', shardKey, loadShard);
255
+ for (const bucket of Object.keys(fwdData)) {
256
+ const gidStr = String(deadGid);
257
+ if (fwdData[bucket] && fwdData[bucket][gidStr]) {
258
+ // Before clearing, find the targets so we can clean reverse bitmaps
259
+ const targets = RoaringBitmap32.deserialize(
260
+ toBytes(fwdData[bucket][gidStr]),
261
+ true,
262
+ ).toArray();
263
+
264
+ // Clear this node's outgoing bitmap
265
+ const empty = new RoaringBitmap32();
266
+ fwdData[bucket][gidStr] = empty.serialize(true);
267
+
268
+ // Remove deadGid from each target's reverse bitmap
269
+ for (const targetGid of targets) {
270
+ const targetNodeId = this._findNodeIdByGlobal(targetGid, metaCache, loadShard);
271
+ if (targetNodeId) {
272
+ const targetShard = computeShardKey(targetNodeId);
273
+ const revData = this._getOrLoadEdgeShard(revCache, 'rev', targetShard, loadShard);
274
+ const targetGidStr = String(targetGid);
275
+ if (revData[bucket] && revData[bucket][targetGidStr]) {
276
+ const bm = RoaringBitmap32.deserialize(
277
+ toBytes(revData[bucket][targetGidStr]),
278
+ true,
279
+ );
280
+ bm.remove(deadGid);
281
+ revData[bucket][targetGidStr] = bm.serialize(true);
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+
288
+ // Clear the dead node's own incoming edges (reverse shard)
289
+ const revData = this._getOrLoadEdgeShard(revCache, 'rev', shardKey, loadShard);
290
+ for (const bucket of Object.keys(revData)) {
291
+ const gidStr = String(deadGid);
292
+ if (revData[bucket] && revData[bucket][gidStr]) {
293
+ const sources = RoaringBitmap32.deserialize(
294
+ toBytes(revData[bucket][gidStr]),
295
+ true,
296
+ ).toArray();
297
+
298
+ const empty = new RoaringBitmap32();
299
+ revData[bucket][gidStr] = empty.serialize(true);
300
+
301
+ // Remove deadGid from each source's forward bitmap
302
+ for (const sourceGid of sources) {
303
+ const sourceNodeId = this._findNodeIdByGlobal(sourceGid, metaCache, loadShard);
304
+ if (sourceNodeId) {
305
+ const sourceShard = computeShardKey(sourceNodeId);
306
+ const fwdDataPeer = this._getOrLoadEdgeShard(fwdCache, 'fwd', sourceShard, loadShard);
307
+ const sourceGidStr = String(sourceGid);
308
+ if (fwdDataPeer[bucket] && fwdDataPeer[bucket][sourceGidStr]) {
309
+ const bm = RoaringBitmap32.deserialize(
310
+ toBytes(fwdDataPeer[bucket][sourceGidStr]),
311
+ true,
312
+ );
313
+ bm.remove(deadGid);
314
+ fwdDataPeer[bucket][sourceGidStr] = bm.serialize(true);
315
+ }
316
+ }
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Reverse-looks up a nodeId from a globalId using the pre-built reverse map.
324
+ *
325
+ * @param {number} globalId
326
+ * @param {Map<string, MetaShard>} metaCache
327
+ * @param {(path: string) => Uint8Array|undefined} loadShard
328
+ * @returns {string|undefined}
329
+ * @private
330
+ */
331
+ _findNodeIdByGlobal(globalId, metaCache, loadShard) {
332
+ // The shard key is encoded in the upper byte of the globalId
333
+ const shardByte = (globalId >>> 24) & 0xff;
334
+ const shardKey = shardByte.toString(16).padStart(2, '0');
335
+ const meta = this._getOrLoadMeta(shardKey, metaCache, loadShard);
336
+ return meta.globalToNode.get(globalId);
337
+ }
338
+
339
+ // ── Label operations ──────────────────────────────────────────────────────
340
+
341
+ /**
342
+ * Ensures a label exists in the registry; returns true if newly added.
343
+ *
344
+ * @param {string} label
345
+ * @param {Record<string, number>} labels
346
+ * @returns {boolean}
347
+ * @private
348
+ */
349
+ _ensureLabel(label, labels) {
350
+ if (Object.prototype.hasOwnProperty.call(labels, label)) {
351
+ return false;
352
+ }
353
+ let maxId = -1;
354
+ for (const id of Object.values(labels)) {
355
+ if (id > maxId) {
356
+ maxId = id;
357
+ }
358
+ }
359
+ labels[label] = maxId + 1;
360
+ return true;
361
+ }
362
+
363
+ // ── Edge operations ───────────────────────────────────────────────────────
364
+
365
+ /**
366
+ * @param {{from: string, to: string, label: string}} edge
367
+ * @param {Record<string, number>} labels
368
+ * @param {Map<string, MetaShard>} metaCache
369
+ * @param {Map<string, EdgeShardData>} fwdCache
370
+ * @param {Map<string, EdgeShardData>} revCache
371
+ * @param {(path: string) => Uint8Array|undefined} loadShard
372
+ * @private
373
+ */
374
+ _handleEdgeAdd(edge, labels, metaCache, fwdCache, revCache, loadShard) {
375
+ const fromMeta = this._getOrLoadMeta(computeShardKey(edge.from), metaCache, loadShard);
376
+ const toMeta = this._getOrLoadMeta(computeShardKey(edge.to), metaCache, loadShard);
377
+ const fromGid = this._findGlobalId(fromMeta, edge.from);
378
+ const toGid = this._findGlobalId(toMeta, edge.to);
379
+ if (fromGid === undefined || toGid === undefined) {
380
+ return;
381
+ }
382
+
383
+ const labelId = String(labels[edge.label]);
384
+ const fromShard = computeShardKey(edge.from);
385
+ const toShard = computeShardKey(edge.to);
386
+
387
+ this._addToEdgeBitmap(fwdCache, { shardKey: fromShard, bucket: 'all', owner: fromGid, target: toGid, dir: 'fwd' }, loadShard);
388
+ this._addToEdgeBitmap(fwdCache, { shardKey: fromShard, bucket: labelId, owner: fromGid, target: toGid, dir: 'fwd' }, loadShard);
389
+ this._addToEdgeBitmap(revCache, { shardKey: toShard, bucket: 'all', owner: toGid, target: fromGid, dir: 'rev' }, loadShard);
390
+ this._addToEdgeBitmap(revCache, { shardKey: toShard, bucket: labelId, owner: toGid, target: fromGid, dir: 'rev' }, loadShard);
391
+ }
392
+
393
+ /**
394
+ * @param {{from: string, to: string, label: string}} edge
395
+ * @param {Record<string, number>} labels
396
+ * @param {Map<string, MetaShard>} metaCache
397
+ * @param {Map<string, EdgeShardData>} fwdCache
398
+ * @param {Map<string, EdgeShardData>} revCache
399
+ * @param {(path: string) => Uint8Array|undefined} loadShard
400
+ * @private
401
+ */
402
+ _handleEdgeRemove(edge, labels, metaCache, fwdCache, revCache, loadShard) {
403
+ const fromMeta = this._getOrLoadMeta(computeShardKey(edge.from), metaCache, loadShard);
404
+ const toMeta = this._getOrLoadMeta(computeShardKey(edge.to), metaCache, loadShard);
405
+ const fromGid = this._findGlobalId(fromMeta, edge.from);
406
+ const toGid = this._findGlobalId(toMeta, edge.to);
407
+ if (fromGid === undefined || toGid === undefined) {
408
+ return;
409
+ }
410
+
411
+ if (labels[edge.label] === undefined) {
412
+ return;
413
+ }
414
+
415
+ const labelId = String(labels[edge.label]);
416
+ const fromShard = computeShardKey(edge.from);
417
+ const toShard = computeShardKey(edge.to);
418
+
419
+ this._removeFromEdgeBitmap(fwdCache, { shardKey: fromShard, bucket: labelId, owner: fromGid, target: toGid, dir: 'fwd' }, loadShard);
420
+ this._removeFromEdgeBitmap(revCache, { shardKey: toShard, bucket: labelId, owner: toGid, target: fromGid, dir: 'rev' }, loadShard);
421
+
422
+ this._recomputeAllBucket(fwdCache, fromShard, fromGid, labels, loadShard, 'fwd');
423
+ this._recomputeAllBucket(revCache, toShard, toGid, labels, loadShard, 'rev');
424
+ }
425
+
426
+ // ── Edge bitmap helpers ───────────────────────────────────────────────────
427
+
428
+ /**
429
+ * @param {Map<string, EdgeShardData>} cache
430
+ * @param {{ shardKey: string, bucket: string, owner: number, target: number, dir: string }} opts
431
+ * @param {(path: string) => Uint8Array|undefined} loadShard
432
+ * @private
433
+ */
434
+ _addToEdgeBitmap(cache, opts, loadShard) {
435
+ const { shardKey, bucket, owner, target, dir } = opts;
436
+ const data = this._getOrLoadEdgeShard(cache, dir, shardKey, loadShard);
437
+ const bm = this._deserializeBitmap(data, bucket, String(owner));
438
+ bm.add(target);
439
+ if (!data[bucket]) { data[bucket] = {}; }
440
+ data[bucket][String(owner)] = bm.serialize(true);
441
+ }
442
+
443
+ /**
444
+ * @param {Map<string, EdgeShardData>} cache
445
+ * @param {{ shardKey: string, bucket: string, owner: number, target: number, dir: string }} opts
446
+ * @param {(path: string) => Uint8Array|undefined} loadShard
447
+ * @private
448
+ */
449
+ _removeFromEdgeBitmap(cache, opts, loadShard) {
450
+ const { shardKey, bucket, owner, target, dir } = opts;
451
+ const data = this._getOrLoadEdgeShard(cache, dir, shardKey, loadShard);
452
+ const bm = this._deserializeBitmap(data, bucket, String(owner));
453
+ bm.remove(target);
454
+ if (!data[bucket]) { data[bucket] = {}; }
455
+ data[bucket][String(owner)] = bm.serialize(true);
456
+ }
457
+
458
+ /**
459
+ * Recomputes the 'all' bucket for a given owner by OR-ing all per-label bitmaps.
460
+ *
461
+ * @param {Map<string, EdgeShardData>} cache
462
+ * @param {string} shardKey
463
+ * @param {number} owner
464
+ * @param {Record<string, number>} labels
465
+ * @param {(path: string) => Uint8Array|undefined} loadShard
466
+ * @param {string} dir
467
+ * @private
468
+ */
469
+ _recomputeAllBucket(cache, shardKey, owner, labels, loadShard, dir) {
470
+ const data = this._getOrLoadEdgeShard(cache, dir, shardKey, loadShard);
471
+ const RoaringBitmap32 = getRoaringBitmap32();
472
+ const merged = new RoaringBitmap32();
473
+ const ownerStr = String(owner);
474
+
475
+ for (const labelId of Object.values(labels)) {
476
+ const bucket = String(labelId);
477
+ if (data[bucket] && data[bucket][ownerStr]) {
478
+ const bm = RoaringBitmap32.deserialize(
479
+ toBytes(data[bucket][ownerStr]),
480
+ true,
481
+ );
482
+ merged.orInPlace(bm);
483
+ }
484
+ }
485
+
486
+ if (!data.all) { data.all = {}; }
487
+ data.all[ownerStr] = merged.serialize(true);
488
+ }
489
+
490
+ // ── Property operations ───────────────────────────────────────────────────
491
+
492
+ /**
493
+ * Handles PropSet entries and flushes dirty props shards.
494
+ *
495
+ * @param {import('../types/PatchDiff.js').PropDiffEntry[]} propsChanged
496
+ * @param {(path: string) => Uint8Array|undefined} loadShard
497
+ * @param {Record<string, Uint8Array>} out
498
+ * @private
499
+ */
500
+ _handleProps(propsChanged, loadShard, out) {
501
+ if (propsChanged.length === 0) {
502
+ return;
503
+ }
504
+
505
+ /** @type {Map<string, Map<string, Record<string, unknown>>>} */
506
+ const shardMap = new Map();
507
+
508
+ for (const prop of propsChanged) {
509
+ const shardKey = computeShardKey(prop.nodeId);
510
+ if (!shardMap.has(shardKey)) {
511
+ shardMap.set(shardKey, this._loadProps(shardKey, loadShard));
512
+ }
513
+ const shard = /** @type {Map<string, Record<string, unknown>>} */ (shardMap.get(shardKey));
514
+ let nodeProps = shard.get(prop.nodeId);
515
+ if (!nodeProps) {
516
+ nodeProps = Object.create(null);
517
+ shard.set(prop.nodeId, /** @type {Record<string, unknown>} */ (nodeProps));
518
+ } else if (Object.getPrototypeOf(nodeProps) !== null) {
519
+ const safeProps = Object.assign(Object.create(null), nodeProps);
520
+ shard.set(prop.nodeId, safeProps);
521
+ nodeProps = safeProps;
522
+ }
523
+ /** @type {Record<string, unknown>} */ (nodeProps)[prop.key] = prop.value;
524
+ }
525
+
526
+ for (const [shardKey, shard] of shardMap) {
527
+ out[`props_${shardKey}.cbor`] = this._saveProps(shard);
528
+ }
529
+ }
530
+
531
+ // ── Meta shard I/O ────────────────────────────────────────────────────────
532
+
533
+ /**
534
+ * @param {string} shardKey
535
+ * @param {Map<string, MetaShard>} cache
536
+ * @param {(path: string) => Uint8Array|undefined} loadShard
537
+ * @returns {MetaShard}
538
+ * @private
539
+ */
540
+ _getOrLoadMeta(shardKey, cache, loadShard) {
541
+ const cached = cache.get(shardKey);
542
+ if (cached) {
543
+ return cached;
544
+ }
545
+ const meta = this._loadMeta(shardKey, loadShard);
546
+ cache.set(shardKey, meta);
547
+ return meta;
548
+ }
549
+
550
+ /**
551
+ * @param {string} shardKey
552
+ * @param {(path: string) => Uint8Array|undefined} loadShard
553
+ * @returns {MetaShard}
554
+ * @private
555
+ */
556
+ _loadMeta(shardKey, loadShard) {
557
+ const RoaringBitmap32 = getRoaringBitmap32();
558
+ const buf = loadShard(`meta_${shardKey}.cbor`);
559
+ if (!buf) {
560
+ return {
561
+ nodeToGlobal: [],
562
+ nextLocalId: 0,
563
+ aliveBitmap: new RoaringBitmap32(),
564
+ globalToNode: new Map(),
565
+ nodeToGlobalMap: new Map(),
566
+ };
567
+ }
568
+ const raw = /** @type {{ nodeToGlobal: Array<[string, number]> | Record<string, number>, alive: Uint8Array | number[], nextLocalId: number }} */ (this._codec.decode(buf));
569
+ const entries = Array.isArray(raw.nodeToGlobal)
570
+ ? raw.nodeToGlobal
571
+ : Object.entries(raw.nodeToGlobal);
572
+ const alive = raw.alive && raw.alive.length > 0
573
+ ? RoaringBitmap32.deserialize(toBytes(raw.alive), true)
574
+ : new RoaringBitmap32();
575
+
576
+ // Build O(1) lookup maps from the entries array
577
+ /** @type {Map<number, string>} */
578
+ const globalToNode = new Map();
579
+ /** @type {Map<string, number>} */
580
+ const nodeToGlobalMap = new Map();
581
+ for (const [nodeId, gid] of entries) {
582
+ globalToNode.set(Number(gid), nodeId);
583
+ nodeToGlobalMap.set(nodeId, Number(gid));
584
+ }
585
+
586
+ return { nodeToGlobal: entries, nextLocalId: raw.nextLocalId, aliveBitmap: alive, globalToNode, nodeToGlobalMap };
587
+ }
588
+
589
+ /**
590
+ * Serialises and flushes all dirty meta shards into `out`.
591
+ *
592
+ * @param {Map<string, MetaShard>} metaCache
593
+ * @param {Record<string, Uint8Array>} out
594
+ * @private
595
+ */
596
+ _flushMeta(metaCache, out) {
597
+ for (const [shardKey, meta] of metaCache) {
598
+ const shard = {
599
+ nodeToGlobal: meta.nodeToGlobal,
600
+ nextLocalId: meta.nextLocalId,
601
+ alive: meta.aliveBitmap.serialize(true),
602
+ };
603
+ out[`meta_${shardKey}.cbor`] = this._codec.encode(shard).slice();
604
+ }
605
+ }
606
+
607
+ // ── Edge shard I/O ────────────────────────────────────────────────────────
608
+
609
+ /**
610
+ * @param {Map<string, EdgeShardData>} cache
611
+ * @param {string} dir
612
+ * @param {string} shardKey
613
+ * @param {(path: string) => Uint8Array|undefined} loadShard
614
+ * @returns {EdgeShardData}
615
+ * @private
616
+ */
617
+ _getOrLoadEdgeShard(cache, dir, shardKey, loadShard) {
618
+ const cacheKey = `${dir}_${shardKey}`;
619
+ const cached = cache.get(cacheKey);
620
+ if (cached) {
621
+ return cached;
622
+ }
623
+ const data = this._loadEdgeShard(dir, shardKey, loadShard);
624
+ cache.set(cacheKey, data);
625
+ return data;
626
+ }
627
+
628
+ /**
629
+ * @param {string} dir
630
+ * @param {string} shardKey
631
+ * @param {(path: string) => Uint8Array|undefined} loadShard
632
+ * @returns {EdgeShardData}
633
+ * @private
634
+ */
635
+ _loadEdgeShard(dir, shardKey, loadShard) {
636
+ const buf = loadShard(`${dir}_${shardKey}.cbor`);
637
+ if (!buf) {
638
+ return {};
639
+ }
640
+ return /** @type {EdgeShardData} */ (this._codec.decode(buf));
641
+ }
642
+
643
+ /**
644
+ * Flushes all dirty edge shards for a direction into `out`.
645
+ *
646
+ * @param {Map<string, EdgeShardData>} cache
647
+ * @param {string} dir
648
+ * @param {Record<string, Uint8Array>} out
649
+ * @private
650
+ */
651
+ _flushEdgeShards(cache, dir, out) {
652
+ const prefix = `${dir}_`;
653
+ for (const [cacheKey, data] of cache) {
654
+ if (!cacheKey.startsWith(prefix)) {
655
+ continue;
656
+ }
657
+ const path = `${cacheKey}.cbor`;
658
+ out[path] = this._codec.encode(data).slice();
659
+ }
660
+ }
661
+
662
+ // ── Labels I/O ────────────────────────────────────────────────────────────
663
+
664
+ /**
665
+ * @param {(path: string) => Uint8Array|undefined} loadShard
666
+ * @returns {Record<string, number>}
667
+ * @private
668
+ */
669
+ _loadLabels(loadShard) {
670
+ const buf = loadShard('labels.cbor');
671
+ if (!buf) {
672
+ return Object.create(null);
673
+ }
674
+ const decoded = /** @type {Record<string, number>|Array<[string, number]>} */ (this._codec.decode(buf));
675
+ /** @type {Record<string, number>} */
676
+ const labels = Object.create(null);
677
+ const entries = Array.isArray(decoded) ? decoded : Object.entries(decoded);
678
+ for (const [label, id] of entries) {
679
+ labels[label] = id;
680
+ }
681
+ return labels;
682
+ }
683
+
684
+ /**
685
+ * @param {Record<string, number>} labels
686
+ * @returns {Uint8Array}
687
+ * @private
688
+ */
689
+ _saveLabels(labels) {
690
+ const entries = Object.entries(labels).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
691
+ return this._codec.encode(entries).slice();
692
+ }
693
+
694
+ // ── Props I/O ─────────────────────────────────────────────────────────────
695
+
696
+ /**
697
+ * @param {string} shardKey
698
+ * @param {(path: string) => Uint8Array|undefined} loadShard
699
+ * @returns {Map<string, Record<string, unknown>>}
700
+ * @private
701
+ */
702
+ _loadProps(shardKey, loadShard) {
703
+ const buf = loadShard(`props_${shardKey}.cbor`);
704
+ /** @type {Map<string, Record<string, unknown>>} */
705
+ const map = new Map();
706
+ if (!buf) {
707
+ return map;
708
+ }
709
+ const decoded = this._codec.decode(buf);
710
+ if (Array.isArray(decoded)) {
711
+ for (const [nodeId, props] of decoded) {
712
+ const safeProps = Object.assign(
713
+ Object.create(null),
714
+ (props && typeof props === 'object') ? props : {},
715
+ );
716
+ map.set(nodeId, safeProps);
717
+ }
718
+ }
719
+ return map;
720
+ }
721
+
722
+ /**
723
+ * @param {Map<string, Record<string, unknown>>} shard
724
+ * @returns {Uint8Array}
725
+ * @private
726
+ */
727
+ _saveProps(shard) {
728
+ const entries = [...shard.entries()];
729
+ return this._codec.encode(entries).slice();
730
+ }
731
+
732
+ // ── Utility ───────────────────────────────────────────────────────────────
733
+
734
+ /**
735
+ * Finds the globalId for a nodeId in a MetaShard via the O(1) forward map.
736
+ *
737
+ * @param {MetaShard} meta
738
+ * @param {string} nodeId
739
+ * @returns {number|undefined}
740
+ * @private
741
+ */
742
+ _findGlobalId(meta, nodeId) {
743
+ return meta.nodeToGlobalMap.get(nodeId);
744
+ }
745
+
746
+ /**
747
+ * Deserializes a bitmap from edge shard data, or creates a new one.
748
+ *
749
+ * @param {EdgeShardData} data
750
+ * @param {string} bucket
751
+ * @param {string} ownerStr
752
+ * @returns {import('../utils/roaring.js').RoaringBitmapSubset}
753
+ * @private
754
+ */
755
+ _deserializeBitmap(data, bucket, ownerStr) {
756
+ const RoaringBitmap32 = getRoaringBitmap32();
757
+ if (data[bucket] && data[bucket][ownerStr]) {
758
+ return RoaringBitmap32.deserialize(
759
+ toBytes(data[bucket][ownerStr]),
760
+ true,
761
+ );
762
+ }
763
+ return new RoaringBitmap32();
764
+ }
765
+ }