@git-stunts/git-warp 11.5.0 → 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 (51) hide show
  1. package/README.md +145 -1
  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 +62 -2
  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/BitmapIndexReader.js +32 -10
  15. package/src/domain/services/BitmapNeighborProvider.js +178 -0
  16. package/src/domain/services/CheckpointMessageCodec.js +3 -3
  17. package/src/domain/services/CheckpointService.js +77 -12
  18. package/src/domain/services/GraphTraversal.js +1239 -0
  19. package/src/domain/services/IncrementalIndexUpdater.js +765 -0
  20. package/src/domain/services/JoinReducer.js +310 -46
  21. package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
  22. package/src/domain/services/LogicalIndexBuildService.js +108 -0
  23. package/src/domain/services/LogicalIndexReader.js +315 -0
  24. package/src/domain/services/LogicalTraversal.js +321 -202
  25. package/src/domain/services/MaterializedViewService.js +379 -0
  26. package/src/domain/services/ObserverView.js +138 -47
  27. package/src/domain/services/PatchBuilderV2.js +3 -3
  28. package/src/domain/services/PropertyIndexBuilder.js +64 -0
  29. package/src/domain/services/PropertyIndexReader.js +111 -0
  30. package/src/domain/services/SyncController.js +576 -0
  31. package/src/domain/services/TemporalQuery.js +128 -14
  32. package/src/domain/types/PatchDiff.js +90 -0
  33. package/src/domain/types/WarpTypesV2.js +4 -4
  34. package/src/domain/utils/MinHeap.js +45 -17
  35. package/src/domain/utils/canonicalCbor.js +36 -0
  36. package/src/domain/utils/fnv1a.js +20 -0
  37. package/src/domain/utils/roaring.js +14 -3
  38. package/src/domain/utils/shardKey.js +40 -0
  39. package/src/domain/utils/toBytes.js +17 -0
  40. package/src/domain/utils/validateShardOid.js +13 -0
  41. package/src/domain/warp/_internal.js +0 -9
  42. package/src/domain/warp/_wiredMethods.d.ts +8 -2
  43. package/src/domain/warp/checkpoint.methods.js +21 -5
  44. package/src/domain/warp/materialize.methods.js +17 -5
  45. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  46. package/src/domain/warp/query.methods.js +78 -12
  47. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  48. package/src/ports/BlobPort.js +1 -1
  49. package/src/ports/NeighborProviderPort.js +59 -0
  50. package/src/ports/SeekCachePort.js +4 -3
  51. package/src/domain/warp/sync.methods.js +0 -554
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Orchestrates building, persisting, and loading a MaterializedView
3
+ * composed of a LogicalIndex + PropertyIndexReader.
4
+ *
5
+ * Five entry points:
6
+ * - `build(state)` — from a WarpStateV5 (in-memory)
7
+ * - `persistIndexTree(tree, persistence)` — write shards to Git storage
8
+ * - `loadFromOids(shardOids, storage)` — hydrate from blob OIDs
9
+ * - `applyDiff(existingTree, diff, state)` — incremental update from PatchDiff
10
+ * - `verifyIndex({ state, logicalIndex, options })` — cross-provider verification
11
+ *
12
+ * @module domain/services/MaterializedViewService
13
+ */
14
+
15
+ import defaultCodec from '../utils/defaultCodec.js';
16
+ import nullLogger from '../utils/nullLogger.js';
17
+ import LogicalIndexBuildService from './LogicalIndexBuildService.js';
18
+ import LogicalIndexReader from './LogicalIndexReader.js';
19
+ import PropertyIndexReader from './PropertyIndexReader.js';
20
+ import IncrementalIndexUpdater from './IncrementalIndexUpdater.js';
21
+ import { orsetElements, orsetContains } from '../crdt/ORSet.js';
22
+ import { decodeEdgeKey } from './KeyCodec.js';
23
+
24
+ /**
25
+ * @typedef {import('./BitmapNeighborProvider.js').LogicalIndex} LogicalIndex
26
+ */
27
+
28
+ /**
29
+ * @typedef {Object} BuildResult
30
+ * @property {Record<string, Uint8Array>} tree
31
+ * @property {LogicalIndex} logicalIndex
32
+ * @property {PropertyIndexReader} propertyReader
33
+ * @property {Record<string, unknown>} receipt
34
+ */
35
+
36
+ /**
37
+ * @typedef {Object} LoadResult
38
+ * @property {LogicalIndex} logicalIndex
39
+ * @property {PropertyIndexReader} propertyReader
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} VerifyError
44
+ * @property {string} nodeId
45
+ * @property {string} direction
46
+ * @property {string[]} expected
47
+ * @property {string[]} actual
48
+ */
49
+
50
+ /**
51
+ * @typedef {Object} VerifyResult
52
+ * @property {number} passed
53
+ * @property {number} failed
54
+ * @property {VerifyError[]} errors
55
+ * @property {number} seed
56
+ */
57
+
58
+ /**
59
+ * Creates a PropertyIndexReader backed by an in-memory tree map.
60
+ *
61
+ * @param {Record<string, Uint8Array>} tree
62
+ * @param {import('../../ports/CodecPort.js').default} codec
63
+ * @returns {PropertyIndexReader}
64
+ */
65
+ function buildInMemoryPropertyReader(tree, codec) {
66
+ /** @type {Record<string, string>} */
67
+ const propShardOids = {};
68
+ for (const path of Object.keys(tree)) {
69
+ if (path.startsWith('props_')) {
70
+ propShardOids[path] = path;
71
+ }
72
+ }
73
+
74
+ const storage = /** @type {{ readBlob(oid: string): Promise<Uint8Array> }} */ ({
75
+ readBlob: (oid) => Promise.resolve(tree[oid]),
76
+ });
77
+
78
+ const reader = new PropertyIndexReader({ storage, codec });
79
+ reader.setup(propShardOids);
80
+ return reader;
81
+ }
82
+
83
+ /**
84
+ * Partitions shard OID entries into index vs property buckets.
85
+ *
86
+ * @param {Record<string, string>} shardOids
87
+ * @returns {{ indexOids: Record<string, string>, propOids: Record<string, string> }}
88
+ */
89
+ function partitionShardOids(shardOids) {
90
+ /** @type {Record<string, string>} */
91
+ const indexOids = {};
92
+ /** @type {Record<string, string>} */
93
+ const propOids = {};
94
+
95
+ for (const [path, oid] of Object.entries(shardOids)) {
96
+ if (path.startsWith('props_')) {
97
+ propOids[path] = oid;
98
+ } else {
99
+ indexOids[path] = oid;
100
+ }
101
+ }
102
+ return { indexOids, propOids };
103
+ }
104
+
105
+ /**
106
+ * Mulberry32 PRNG — deterministic 32-bit generator from a seed.
107
+ *
108
+ * @param {number} seed
109
+ * @returns {() => number} Returns values in [0, 1)
110
+ */
111
+ function mulberry32(seed) {
112
+ let t = (seed | 0) + 0x6D2B79F5;
113
+ return () => {
114
+ t = Math.imul(t ^ (t >>> 15), t | 1);
115
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
116
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Selects a deterministic sample of nodes using a seeded PRNG.
122
+ *
123
+ * @param {string[]} allNodes
124
+ * @param {number} sampleRate - Fraction of nodes to select (>0 and <=1)
125
+ * @param {number} seed
126
+ * @returns {string[]}
127
+ */
128
+ function sampleNodes(allNodes, sampleRate, seed) {
129
+ if (sampleRate >= 1) {
130
+ return allNodes;
131
+ }
132
+ if (sampleRate <= 0 || allNodes.length === 0) {
133
+ return [];
134
+ }
135
+ const rng = mulberry32(seed);
136
+ const sampled = allNodes.filter(() => rng() < sampleRate);
137
+ if (sampled.length === 0) {
138
+ sampled.push(allNodes[Math.floor(rng() * allNodes.length)]);
139
+ }
140
+ return sampled;
141
+ }
142
+
143
+ /**
144
+ * Builds adjacency maps from state for ground-truth verification.
145
+ *
146
+ * @param {import('../services/JoinReducer.js').WarpStateV5} state
147
+ * @returns {{ outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>> }}
148
+ */
149
+ function buildGroundTruthAdjacency(state) {
150
+ const outgoing = new Map();
151
+ const incoming = new Map();
152
+
153
+ for (const edgeKey of orsetElements(state.edgeAlive)) {
154
+ const { from, to, label } = decodeEdgeKey(edgeKey);
155
+ if (!orsetContains(state.nodeAlive, from) || !orsetContains(state.nodeAlive, to)) {
156
+ continue;
157
+ }
158
+ if (!outgoing.has(from)) {
159
+ outgoing.set(from, []);
160
+ }
161
+ outgoing.get(from).push({ neighborId: to, label });
162
+ if (!incoming.has(to)) {
163
+ incoming.set(to, []);
164
+ }
165
+ incoming.get(to).push({ neighborId: from, label });
166
+ }
167
+
168
+ return { outgoing, incoming };
169
+ }
170
+
171
+ /**
172
+ * Canonicalizes neighbor edges into deterministic, label-aware signatures.
173
+ *
174
+ * @param {Array<{neighborId: string, label: string}>} edges
175
+ * @returns {string[]}
176
+ */
177
+ function canonicalizeNeighborSignatures(edges) {
178
+ /** @type {Map<string, string[]>} */
179
+ const byNeighbor = new Map();
180
+ for (const { neighborId, label } of edges) {
181
+ let labels = byNeighbor.get(neighborId);
182
+ if (!labels) {
183
+ labels = [];
184
+ byNeighbor.set(neighborId, labels);
185
+ }
186
+ labels.push(label);
187
+ }
188
+ const signatures = [];
189
+ for (const [neighborId, labels] of byNeighbor) {
190
+ signatures.push(JSON.stringify([neighborId, labels.slice().sort()]));
191
+ }
192
+ signatures.sort();
193
+ return signatures;
194
+ }
195
+
196
+ /**
197
+ * Compares bitmap index neighbors against ground-truth adjacency for one node.
198
+ *
199
+ * @param {Object} params
200
+ * @param {string} params.nodeId
201
+ * @param {string} params.direction
202
+ * @param {LogicalIndex} params.logicalIndex
203
+ * @param {Map<string, Array<{neighborId: string, label: string}>>} params.truthMap
204
+ * @returns {VerifyError|null}
205
+ */
206
+ function compareNodeDirection({ nodeId, direction, logicalIndex, truthMap }) {
207
+ const bitmapEdges = logicalIndex.getEdges(nodeId, direction);
208
+ const actual = canonicalizeNeighborSignatures(bitmapEdges);
209
+ const expected = canonicalizeNeighborSignatures(truthMap.get(nodeId) || []);
210
+
211
+ if (actual.length !== expected.length) {
212
+ return { nodeId, direction, expected, actual };
213
+ }
214
+ for (let i = 0; i < actual.length; i++) {
215
+ if (actual[i] !== expected[i]) {
216
+ return { nodeId, direction, expected, actual };
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ export default class MaterializedViewService {
223
+ /**
224
+ * @param {Object} [options]
225
+ * @param {import('../../ports/CodecPort.js').default} [options.codec]
226
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger]
227
+ */
228
+ constructor({ codec, logger } = {}) {
229
+ this._codec = codec || defaultCodec;
230
+ this._logger = logger || nullLogger;
231
+ }
232
+
233
+ /**
234
+ * Builds a complete MaterializedView from WarpStateV5.
235
+ *
236
+ * @param {import('./JoinReducer.js').WarpStateV5} state
237
+ * @returns {BuildResult}
238
+ */
239
+ build(state) {
240
+ const svc = new LogicalIndexBuildService({
241
+ codec: this._codec,
242
+ logger: this._logger,
243
+ });
244
+ const { tree, receipt } = svc.build(state);
245
+
246
+ const logicalIndex = new LogicalIndexReader({ codec: this._codec })
247
+ .loadFromTree(tree)
248
+ .toLogicalIndex();
249
+
250
+ const propertyReader = buildInMemoryPropertyReader(tree, this._codec);
251
+
252
+ return { tree, logicalIndex, propertyReader, receipt };
253
+ }
254
+
255
+ /**
256
+ * Writes each shard as a blob and creates a Git tree object.
257
+ *
258
+ * @param {Record<string, Uint8Array>} tree
259
+ * @param {{ writeBlob(buf: Uint8Array): Promise<string>, writeTree(entries: string[]): Promise<string> }} persistence
260
+ * @returns {Promise<string>} tree OID
261
+ */
262
+ async persistIndexTree(tree, persistence) {
263
+ const paths = Object.keys(tree).sort();
264
+ const oids = await Promise.all(
265
+ paths.map((p) => persistence.writeBlob(tree[p]))
266
+ );
267
+
268
+ const entries = paths.map(
269
+ (path, i) => `100644 blob ${oids[i]}\t${path}`
270
+ );
271
+ return await persistence.writeTree(entries);
272
+ }
273
+
274
+ /**
275
+ * Hydrates a LogicalIndex + PropertyIndexReader from blob OIDs.
276
+ *
277
+ * @param {Record<string, string>} shardOids - path to blob OID
278
+ * @param {{ readBlob(oid: string): Promise<Uint8Array> }} storage
279
+ * @returns {Promise<LoadResult>}
280
+ */
281
+ async loadFromOids(shardOids, storage) {
282
+ const { indexOids, propOids } = partitionShardOids(shardOids);
283
+
284
+ const reader = new LogicalIndexReader({ codec: this._codec });
285
+ await reader.loadFromOids(indexOids, storage);
286
+ const logicalIndex = reader.toLogicalIndex();
287
+
288
+ const propertyReader = new PropertyIndexReader({
289
+ storage: /** @type {import('../../ports/IndexStoragePort.js').default} */ (storage),
290
+ codec: this._codec,
291
+ });
292
+ propertyReader.setup(propOids);
293
+
294
+ return { logicalIndex, propertyReader };
295
+ }
296
+
297
+ /**
298
+ * Applies a PatchDiff incrementally to an existing index tree.
299
+ *
300
+ * @param {Object} params
301
+ * @param {Record<string, Uint8Array>} params.existingTree
302
+ * @param {import('../types/PatchDiff.js').PatchDiff} params.diff
303
+ * @param {import('./JoinReducer.js').WarpStateV5} params.state
304
+ * @returns {BuildResult}
305
+ */
306
+ applyDiff({ existingTree, diff, state }) {
307
+ const updater = new IncrementalIndexUpdater({ codec: this._codec });
308
+ const loadShard = (/** @type {string} */ path) => existingTree[path];
309
+ const dirtyShards = updater.computeDirtyShards({ diff, state, loadShard });
310
+ const tree = { ...existingTree, ...dirtyShards };
311
+
312
+ const logicalIndex = new LogicalIndexReader({ codec: this._codec })
313
+ .loadFromTree(tree)
314
+ .toLogicalIndex();
315
+ const propertyReader = buildInMemoryPropertyReader(tree, this._codec);
316
+
317
+ // Note: receipt.cbor is written only by the full build (LogicalIndexBuildService).
318
+ // IncrementalIndexUpdater never writes a receipt, so the receipt returned here
319
+ // reflects the state at the time of the original full build, not the current
320
+ // incremental update. Consumers should not rely on it for incremental accuracy.
321
+ const receipt = tree['receipt.cbor']
322
+ ? this._codec.decode(tree['receipt.cbor'])
323
+ : {};
324
+
325
+ return {
326
+ tree,
327
+ logicalIndex,
328
+ propertyReader,
329
+ receipt: /** @type {Record<string, unknown>} */ (receipt),
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Verifies index integrity by sampling alive nodes and comparing
335
+ * bitmap neighbor queries against adjacency-based ground truth.
336
+ *
337
+ * @param {Object} params
338
+ * @param {import('./JoinReducer.js').WarpStateV5} params.state
339
+ * @param {LogicalIndex} params.logicalIndex
340
+ * @param {Object} [params.options]
341
+ * @param {number} [params.options.seed] - PRNG seed for reproducible sampling
342
+ * @param {number} [params.options.sampleRate] - Fraction of nodes to check (>0 and <=1, default 0.1)
343
+ * @returns {VerifyResult}
344
+ */
345
+ verifyIndex({ state, logicalIndex, options = {} }) {
346
+ const seed = options.seed ?? (Date.now() & 0x7FFFFFFF);
347
+ const sampleRate = options.sampleRate ?? 0.1;
348
+ const allNodes = [...orsetElements(state.nodeAlive)].sort();
349
+ const sampled = sampleNodes(allNodes, sampleRate, seed);
350
+ const truth = buildGroundTruthAdjacency(state);
351
+
352
+ /** @type {VerifyError[]} */
353
+ const errors = [];
354
+ let passed = 0;
355
+
356
+ for (const nodeId of sampled) {
357
+ if (!logicalIndex.isAlive(nodeId)) {
358
+ errors.push({
359
+ nodeId,
360
+ direction: 'alive',
361
+ expected: ['true'],
362
+ actual: ['false'],
363
+ });
364
+ continue;
365
+ }
366
+ for (const direction of ['out', 'in']) {
367
+ const map = direction === 'out' ? truth.outgoing : truth.incoming;
368
+ const err = compareNodeDirection({ nodeId, direction, logicalIndex, truthMap: map });
369
+ if (err) {
370
+ errors.push(err);
371
+ } else {
372
+ passed++;
373
+ }
374
+ }
375
+ }
376
+
377
+ return { passed, failed: errors.length, errors, seed };
378
+ }
379
+ }
@@ -14,6 +14,9 @@ import LogicalTraversal from './LogicalTraversal.js';
14
14
  import { orsetContains, orsetElements } from '../crdt/ORSet.js';
15
15
  import { decodeEdgeKey } from './KeyCodec.js';
16
16
 
17
+ /** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
18
+ const globRegexCache = new Map();
19
+
17
20
  /**
18
21
  * Tests whether a string matches a glob-style pattern.
19
22
  *
@@ -31,8 +34,12 @@ function matchGlob(pattern, str) {
31
34
  if (!pattern.includes('*')) {
32
35
  return pattern === str;
33
36
  }
34
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
35
- const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
37
+ let regex = globRegexCache.get(pattern);
38
+ if (!regex) {
39
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
40
+ regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
41
+ globRegexCache.set(pattern, regex);
42
+ }
36
43
  return regex.test(str);
37
44
  }
38
45
 
@@ -67,6 +74,106 @@ function filterProps(propsMap, expose, redact) {
67
74
  return filtered;
68
75
  }
69
76
 
77
+ /** @typedef {{ neighborId: string, label: string }} NeighborEntry */
78
+
79
+ /**
80
+ * Sorts a neighbor list by (neighborId, label) using strict codepoint comparison.
81
+ *
82
+ * @param {NeighborEntry[]} list
83
+ */
84
+ function sortNeighbors(list) {
85
+ list.sort((a, b) => {
86
+ if (a.neighborId !== b.neighborId) {
87
+ return a.neighborId < b.neighborId ? -1 : 1;
88
+ }
89
+ return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Builds filtered adjacency maps by scanning all edges in the OR-Set.
95
+ *
96
+ * @param {import('./JoinReducer.js').WarpStateV5} state
97
+ * @param {string} pattern
98
+ * @returns {{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }}
99
+ */
100
+ function buildAdjacencyFromEdges(state, pattern) {
101
+ const outgoing = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
102
+ const incoming = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
103
+
104
+ for (const edgeKey of orsetElements(state.edgeAlive)) {
105
+ const { from, to, label } = decodeEdgeKey(edgeKey);
106
+
107
+ if (!orsetContains(state.nodeAlive, from) || !orsetContains(state.nodeAlive, to)) {
108
+ continue;
109
+ }
110
+ if (!matchGlob(pattern, from) || !matchGlob(pattern, to)) {
111
+ continue;
112
+ }
113
+
114
+ if (!outgoing.has(from)) { outgoing.set(from, []); }
115
+ if (!incoming.has(to)) { incoming.set(to, []); }
116
+
117
+ /** @type {NeighborEntry[]} */ (outgoing.get(from)).push({ neighborId: to, label });
118
+ /** @type {NeighborEntry[]} */ (incoming.get(to)).push({ neighborId: from, label });
119
+ }
120
+
121
+ for (const list of outgoing.values()) { sortNeighbors(list); }
122
+ for (const list of incoming.values()) { sortNeighbors(list); }
123
+ return { outgoing, incoming };
124
+ }
125
+
126
+ /**
127
+ * Processes a single node's edges into the outgoing/incoming adjacency maps.
128
+ *
129
+ * @param {string} id
130
+ * @param {NeighborEntry[]} edges
131
+ * @param {{ visibleSet: Set<string>, outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }} ctx
132
+ */
133
+ function collectNodeEdges(id, edges, ctx) {
134
+ const filtered = edges.filter((e) => ctx.visibleSet.has(e.neighborId));
135
+ if (filtered.length > 0) {
136
+ ctx.outgoing.set(id, filtered);
137
+ }
138
+ for (const { neighborId, label } of filtered) {
139
+ if (!ctx.incoming.has(neighborId)) { ctx.incoming.set(neighborId, []); }
140
+ /** @type {NeighborEntry[]} */ (ctx.incoming.get(neighborId)).push({ neighborId: id, label });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Builds filtered adjacency maps using a BitmapNeighborProvider.
146
+ *
147
+ * For each visible node, queries the provider for outgoing neighbors,
148
+ * then post-filters by glob match. Incoming maps are derived from
149
+ * the outgoing results to avoid duplicate provider calls.
150
+ *
151
+ * @param {import('./BitmapNeighborProvider.js').default} provider
152
+ * @param {string[]} visibleNodes
153
+ * @returns {Promise<{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }>}
154
+ */
155
+ async function buildAdjacencyViaProvider(provider, visibleNodes) {
156
+ const visibleSet = new Set(visibleNodes);
157
+ const outgoing = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
158
+ const incoming = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
159
+ const ctx = { visibleSet, outgoing, incoming };
160
+
161
+ const BATCH = 64;
162
+ for (let i = 0; i < visibleNodes.length; i += BATCH) {
163
+ const chunk = visibleNodes.slice(i, i + BATCH);
164
+ const results = await Promise.all(
165
+ chunk.map(id => provider.getNeighbors(id, 'out').then(edges => ({ id, edges })))
166
+ );
167
+ for (const { id, edges } of results) {
168
+ collectNodeEdges(id, edges, ctx);
169
+ }
170
+ }
171
+
172
+ // Provider returns pre-sorted outgoing; incoming needs sorting
173
+ for (const list of incoming.values()) { sortNeighbors(list); }
174
+ return { outgoing, incoming };
175
+ }
176
+
70
177
  /**
71
178
  * Read-only observer view of a materialized WarpGraph state.
72
179
  *
@@ -101,6 +208,13 @@ export default class ObserverView {
101
208
  /** @type {import('../WarpGraph.js').default} */
102
209
  this._graph = graph;
103
210
 
211
+ /**
212
+ * Cast safety: LogicalTraversal requires the following methods from the
213
+ * graph-like object it wraps:
214
+ * - hasNode(nodeId): Promise<boolean> (line ~96 in LogicalTraversal)
215
+ * - _materializeGraph(): Promise<{adjacency}> (line ~94 in LogicalTraversal)
216
+ * ObserverView implements both: hasNode() at line ~242, _materializeGraph() at line ~214.
217
+ */
104
218
  /** @type {LogicalTraversal} */
105
219
  this.traverse = new LogicalTraversal(/** @type {import('../WarpGraph.js').default} */ (/** @type {unknown} */ (this)));
106
220
  }
@@ -122,60 +236,28 @@ export default class ObserverView {
122
236
  * QueryBuilder and LogicalTraversal.
123
237
  *
124
238
  * Builds a filtered adjacency structure that only includes edges
125
- * where both endpoints pass the match filter.
239
+ * where both endpoints pass the match filter. Uses the parent graph's
240
+ * BitmapNeighborProvider when available for O(1) lookups with post-filter.
126
241
  *
127
- * @returns {Promise<{state: unknown, stateHash: string, adjacency: {outgoing: Map<string, unknown[]>, incoming: Map<string, unknown[]>}}>}
242
+ * @returns {Promise<{state: unknown, stateHash: string, adjacency: {outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]>}}>}
128
243
  * @private
129
244
  */
130
245
  async _materializeGraph() {
131
- const materialized = await /** @type {{ _materializeGraph: () => Promise<{state: import('./JoinReducer.js').WarpStateV5, stateHash: string, adjacency: {outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>>}}> }} */ (this._graph)._materializeGraph();
246
+ const materialized = await /** @type {{ _materializeGraph: () => Promise<{state: import('./JoinReducer.js').WarpStateV5, stateHash: string, provider?: import('./BitmapNeighborProvider.js').default, adjacency: {outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]>}}> }} */ (this._graph)._materializeGraph();
132
247
  const { state, stateHash } = materialized;
133
248
 
134
- // Build filtered adjacency: only edges where both endpoints match
135
- const outgoing = new Map();
136
- const incoming = new Map();
137
-
138
- for (const edgeKey of orsetElements(state.edgeAlive)) {
139
- const { from, to, label } = decodeEdgeKey(edgeKey);
140
-
141
- // Both endpoints must be alive
142
- if (!orsetContains(state.nodeAlive, from) || !orsetContains(state.nodeAlive, to)) {
143
- continue;
144
- }
145
-
146
- // Both endpoints must match the observer pattern
147
- if (!matchGlob(this._matchPattern, from) || !matchGlob(this._matchPattern, to)) {
148
- continue;
149
- }
249
+ /** @type {{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }} */
250
+ let adjacency;
150
251
 
151
- if (!outgoing.has(from)) {
152
- outgoing.set(from, []);
153
- }
154
- if (!incoming.has(to)) {
155
- incoming.set(to, []);
156
- }
157
-
158
- outgoing.get(from).push({ neighborId: to, label });
159
- incoming.get(to).push({ neighborId: from, label });
160
- }
161
-
162
- const sortNeighbors = (/** @type {{ neighborId: string, label: string }[]} */ list) => {
163
- list.sort((/** @type {{ neighborId: string, label: string }} */ a, /** @type {{ neighborId: string, label: string }} */ b) => {
164
- if (a.neighborId !== b.neighborId) {
165
- return a.neighborId < b.neighborId ? -1 : 1;
166
- }
167
- return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
168
- });
169
- };
170
-
171
- for (const list of outgoing.values()) {
172
- sortNeighbors(list);
173
- }
174
- for (const list of incoming.values()) {
175
- sortNeighbors(list);
252
+ if (materialized.provider) {
253
+ const visibleNodes = orsetElements(state.nodeAlive)
254
+ .filter((id) => matchGlob(this._matchPattern, id));
255
+ adjacency = await buildAdjacencyViaProvider(materialized.provider, visibleNodes);
256
+ } else {
257
+ adjacency = buildAdjacencyFromEdges(state, this._matchPattern);
176
258
  }
177
259
 
178
- return { state, stateHash, adjacency: { outgoing, incoming } };
260
+ return { state, stateHash, adjacency };
179
261
  }
180
262
 
181
263
  // ===========================================================================
@@ -260,6 +342,15 @@ export default class ObserverView {
260
342
  * @returns {QueryBuilder} A query builder scoped to this observer
261
343
  */
262
344
  query() {
345
+ /**
346
+ * Cast safety: QueryBuilder requires the following methods from the
347
+ * graph-like object it wraps:
348
+ * - getNodes(): Promise<string[]> (line ~680 in QueryBuilder)
349
+ * - getNodeProps(nodeId): Promise<Map|null> (lines ~691, ~757, ~806 in QueryBuilder)
350
+ * - _materializeGraph(): Promise<{adjacency, stateHash}> (line ~678 in QueryBuilder)
351
+ * ObserverView implements all three: getNodes() at line ~254, getNodeProps() at line ~268,
352
+ * _materializeGraph() at line ~214.
353
+ */
263
354
  return new QueryBuilder(/** @type {import('../WarpGraph.js').default} */ (/** @type {unknown} */ (this)));
264
355
  }
265
356
  }
@@ -221,7 +221,7 @@ export class PatchBuilderV2 {
221
221
  const { edges } = findAttachedData(state, nodeId);
222
222
  for (const edgeKey of edges) {
223
223
  const [from, to, label] = edgeKey.split('\0');
224
- const edgeDots = /** @type {import('../crdt/Dot.js').Dot[]} */ (/** @type {unknown} */ ([...orsetGetDots(state.edgeAlive, edgeKey)]));
224
+ const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)];
225
225
  this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots));
226
226
  // Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots)
227
227
  this._reads.add(edgeKey);
@@ -258,7 +258,7 @@ export class PatchBuilderV2 {
258
258
  }
259
259
  }
260
260
 
261
- const observedDots = /** @type {import('../crdt/Dot.js').Dot[]} */ (/** @type {unknown} */ (state ? [...orsetGetDots(state.nodeAlive, nodeId)] : []));
261
+ const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [];
262
262
  this._ops.push(createNodeRemoveV2(nodeId, observedDots));
263
263
  // Provenance: NodeRemove reads the node (to observe its dots)
264
264
  this._reads.add(nodeId);
@@ -332,7 +332,7 @@ export class PatchBuilderV2 {
332
332
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
333
333
  const state = this._getCurrentState();
334
334
  const edgeKey = encodeEdgeKey(from, to, label);
335
- const observedDots = /** @type {import('../crdt/Dot.js').Dot[]} */ (/** @type {unknown} */ (state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : []));
335
+ const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
336
336
  this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
337
337
  // Provenance: EdgeRemove reads the edge key (to observe its dots)
338
338
  this._reads.add(edgeKey);