@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.
- package/README.md +142 -10
- package/bin/cli/commands/registry.js +4 -0
- package/bin/cli/commands/reindex.js +41 -0
- package/bin/cli/commands/verify-index.js +59 -0
- package/bin/cli/infrastructure.js +7 -2
- package/bin/cli/schemas.js +19 -0
- package/bin/cli/types.js +2 -0
- package/index.d.ts +49 -12
- package/package.json +2 -2
- package/src/domain/WarpGraph.js +40 -0
- package/src/domain/errors/ShardIdOverflowError.js +28 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
- package/src/domain/services/BitmapNeighborProvider.js +178 -0
- package/src/domain/services/CheckpointMessageCodec.js +3 -3
- package/src/domain/services/CheckpointService.js +77 -12
- package/src/domain/services/GraphTraversal.js +1239 -0
- package/src/domain/services/IncrementalIndexUpdater.js +765 -0
- package/src/domain/services/JoinReducer.js +233 -5
- package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
- package/src/domain/services/LogicalIndexBuildService.js +108 -0
- package/src/domain/services/LogicalIndexReader.js +315 -0
- package/src/domain/services/LogicalTraversal.js +321 -202
- package/src/domain/services/MaterializedViewService.js +379 -0
- package/src/domain/services/ObserverView.js +138 -47
- package/src/domain/services/PatchBuilderV2.js +3 -3
- package/src/domain/services/PropertyIndexBuilder.js +64 -0
- package/src/domain/services/PropertyIndexReader.js +111 -0
- package/src/domain/services/TemporalQuery.js +128 -14
- package/src/domain/types/PatchDiff.js +90 -0
- package/src/domain/types/WarpTypesV2.js +4 -4
- package/src/domain/utils/MinHeap.js +45 -17
- package/src/domain/utils/canonicalCbor.js +36 -0
- package/src/domain/utils/fnv1a.js +20 -0
- package/src/domain/utils/roaring.js +14 -3
- package/src/domain/utils/shardKey.js +40 -0
- package/src/domain/utils/toBytes.js +17 -0
- package/src/domain/warp/_wiredMethods.d.ts +7 -1
- package/src/domain/warp/checkpoint.methods.js +21 -5
- package/src/domain/warp/materialize.methods.js +17 -5
- package/src/domain/warp/materializeAdvanced.methods.js +142 -3
- package/src/domain/warp/query.methods.js +78 -12
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
- package/src/ports/BlobPort.js +1 -1
- package/src/ports/NeighborProviderPort.js +59 -0
- package/src/ports/SeekCachePort.js +4 -3
|
@@ -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
|
-
|
|
35
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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);
|