@git-stunts/git-warp 11.5.1 → 12.1.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 +137 -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 +52 -15
- package/package.json +3 -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 +132 -69
- 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/QueryBuilder.js +15 -44
- package/src/domain/services/TemporalQuery.js +128 -14
- package/src/domain/services/TranslationCost.js +8 -24
- 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/matchGlob.js +51 -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 +83 -15
- 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
|
+
}
|
|
@@ -13,28 +13,7 @@ import QueryBuilder from './QueryBuilder.js';
|
|
|
13
13
|
import LogicalTraversal from './LogicalTraversal.js';
|
|
14
14
|
import { orsetContains, orsetElements } from '../crdt/ORSet.js';
|
|
15
15
|
import { decodeEdgeKey } from './KeyCodec.js';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Tests whether a string matches a glob-style pattern.
|
|
19
|
-
*
|
|
20
|
-
* Supports `*` as a wildcard matching zero or more characters.
|
|
21
|
-
* A lone `*` matches everything.
|
|
22
|
-
*
|
|
23
|
-
* @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
|
|
24
|
-
* @param {string} str - The string to test
|
|
25
|
-
* @returns {boolean} True if the string matches the pattern
|
|
26
|
-
*/
|
|
27
|
-
function matchGlob(pattern, str) {
|
|
28
|
-
if (pattern === '*') {
|
|
29
|
-
return true;
|
|
30
|
-
}
|
|
31
|
-
if (!pattern.includes('*')) {
|
|
32
|
-
return pattern === str;
|
|
33
|
-
}
|
|
34
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
35
|
-
const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
|
|
36
|
-
return regex.test(str);
|
|
37
|
-
}
|
|
16
|
+
import { matchGlob } from '../utils/matchGlob.js';
|
|
38
17
|
|
|
39
18
|
/**
|
|
40
19
|
* Filters a properties Map based on expose and redact lists.
|
|
@@ -67,6 +46,106 @@ function filterProps(propsMap, expose, redact) {
|
|
|
67
46
|
return filtered;
|
|
68
47
|
}
|
|
69
48
|
|
|
49
|
+
/** @typedef {{ neighborId: string, label: string }} NeighborEntry */
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sorts a neighbor list by (neighborId, label) using strict codepoint comparison.
|
|
53
|
+
*
|
|
54
|
+
* @param {NeighborEntry[]} list
|
|
55
|
+
*/
|
|
56
|
+
function sortNeighbors(list) {
|
|
57
|
+
list.sort((a, b) => {
|
|
58
|
+
if (a.neighborId !== b.neighborId) {
|
|
59
|
+
return a.neighborId < b.neighborId ? -1 : 1;
|
|
60
|
+
}
|
|
61
|
+
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds filtered adjacency maps by scanning all edges in the OR-Set.
|
|
67
|
+
*
|
|
68
|
+
* @param {import('./JoinReducer.js').WarpStateV5} state
|
|
69
|
+
* @param {string|string[]} pattern
|
|
70
|
+
* @returns {{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }}
|
|
71
|
+
*/
|
|
72
|
+
function buildAdjacencyFromEdges(state, pattern) {
|
|
73
|
+
const outgoing = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
|
|
74
|
+
const incoming = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
|
|
75
|
+
|
|
76
|
+
for (const edgeKey of orsetElements(state.edgeAlive)) {
|
|
77
|
+
const { from, to, label } = decodeEdgeKey(edgeKey);
|
|
78
|
+
|
|
79
|
+
if (!orsetContains(state.nodeAlive, from) || !orsetContains(state.nodeAlive, to)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!matchGlob(pattern, from) || !matchGlob(pattern, to)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!outgoing.has(from)) { outgoing.set(from, []); }
|
|
87
|
+
if (!incoming.has(to)) { incoming.set(to, []); }
|
|
88
|
+
|
|
89
|
+
/** @type {NeighborEntry[]} */ (outgoing.get(from)).push({ neighborId: to, label });
|
|
90
|
+
/** @type {NeighborEntry[]} */ (incoming.get(to)).push({ neighborId: from, label });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const list of outgoing.values()) { sortNeighbors(list); }
|
|
94
|
+
for (const list of incoming.values()) { sortNeighbors(list); }
|
|
95
|
+
return { outgoing, incoming };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Processes a single node's edges into the outgoing/incoming adjacency maps.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} id
|
|
102
|
+
* @param {NeighborEntry[]} edges
|
|
103
|
+
* @param {{ visibleSet: Set<string>, outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }} ctx
|
|
104
|
+
*/
|
|
105
|
+
function collectNodeEdges(id, edges, ctx) {
|
|
106
|
+
const filtered = edges.filter((e) => ctx.visibleSet.has(e.neighborId));
|
|
107
|
+
if (filtered.length > 0) {
|
|
108
|
+
ctx.outgoing.set(id, filtered);
|
|
109
|
+
}
|
|
110
|
+
for (const { neighborId, label } of filtered) {
|
|
111
|
+
if (!ctx.incoming.has(neighborId)) { ctx.incoming.set(neighborId, []); }
|
|
112
|
+
/** @type {NeighborEntry[]} */ (ctx.incoming.get(neighborId)).push({ neighborId: id, label });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Builds filtered adjacency maps using a BitmapNeighborProvider.
|
|
118
|
+
*
|
|
119
|
+
* For each visible node, queries the provider for outgoing neighbors,
|
|
120
|
+
* then post-filters by glob match. Incoming maps are derived from
|
|
121
|
+
* the outgoing results to avoid duplicate provider calls.
|
|
122
|
+
*
|
|
123
|
+
* @param {import('./BitmapNeighborProvider.js').default} provider
|
|
124
|
+
* @param {string[]} visibleNodes
|
|
125
|
+
* @returns {Promise<{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }>}
|
|
126
|
+
*/
|
|
127
|
+
async function buildAdjacencyViaProvider(provider, visibleNodes) {
|
|
128
|
+
const visibleSet = new Set(visibleNodes);
|
|
129
|
+
const outgoing = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
|
|
130
|
+
const incoming = /** @type {Map<string, NeighborEntry[]>} */ (new Map());
|
|
131
|
+
const ctx = { visibleSet, outgoing, incoming };
|
|
132
|
+
|
|
133
|
+
const BATCH = 64;
|
|
134
|
+
for (let i = 0; i < visibleNodes.length; i += BATCH) {
|
|
135
|
+
const chunk = visibleNodes.slice(i, i + BATCH);
|
|
136
|
+
const results = await Promise.all(
|
|
137
|
+
chunk.map(id => provider.getNeighbors(id, 'out').then(edges => ({ id, edges })))
|
|
138
|
+
);
|
|
139
|
+
for (const { id, edges } of results) {
|
|
140
|
+
collectNodeEdges(id, edges, ctx);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Provider returns pre-sorted outgoing; incoming needs sorting
|
|
145
|
+
for (const list of incoming.values()) { sortNeighbors(list); }
|
|
146
|
+
return { outgoing, incoming };
|
|
147
|
+
}
|
|
148
|
+
|
|
70
149
|
/**
|
|
71
150
|
* Read-only observer view of a materialized WarpGraph state.
|
|
72
151
|
*
|
|
@@ -80,7 +159,7 @@ export default class ObserverView {
|
|
|
80
159
|
* @param {Object} options
|
|
81
160
|
* @param {string} options.name - Observer name
|
|
82
161
|
* @param {Object} options.config - Observer configuration
|
|
83
|
-
* @param {string} options.config.match - Glob pattern for visible nodes
|
|
162
|
+
* @param {string|string[]} options.config.match - Glob pattern(s) for visible nodes
|
|
84
163
|
* @param {string[]} [options.config.expose] - Property keys to include
|
|
85
164
|
* @param {string[]} [options.config.redact] - Property keys to exclude (takes precedence over expose)
|
|
86
165
|
* @param {import('../WarpGraph.js').default} options.graph - The source WarpGraph instance
|
|
@@ -89,7 +168,7 @@ export default class ObserverView {
|
|
|
89
168
|
/** @type {string} */
|
|
90
169
|
this._name = name;
|
|
91
170
|
|
|
92
|
-
/** @type {string} */
|
|
171
|
+
/** @type {string|string[]} */
|
|
93
172
|
this._matchPattern = config.match;
|
|
94
173
|
|
|
95
174
|
/** @type {string[]|undefined} */
|
|
@@ -101,6 +180,13 @@ export default class ObserverView {
|
|
|
101
180
|
/** @type {import('../WarpGraph.js').default} */
|
|
102
181
|
this._graph = graph;
|
|
103
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Cast safety: LogicalTraversal requires the following methods from the
|
|
185
|
+
* graph-like object it wraps:
|
|
186
|
+
* - hasNode(nodeId): Promise<boolean> (line ~96 in LogicalTraversal)
|
|
187
|
+
* - _materializeGraph(): Promise<{adjacency}> (line ~94 in LogicalTraversal)
|
|
188
|
+
* ObserverView implements both: hasNode() at line ~242, _materializeGraph() at line ~214.
|
|
189
|
+
*/
|
|
104
190
|
/** @type {LogicalTraversal} */
|
|
105
191
|
this.traverse = new LogicalTraversal(/** @type {import('../WarpGraph.js').default} */ (/** @type {unknown} */ (this)));
|
|
106
192
|
}
|
|
@@ -122,60 +208,28 @@ export default class ObserverView {
|
|
|
122
208
|
* QueryBuilder and LogicalTraversal.
|
|
123
209
|
*
|
|
124
210
|
* Builds a filtered adjacency structure that only includes edges
|
|
125
|
-
* where both endpoints pass the match filter.
|
|
211
|
+
* where both endpoints pass the match filter. Uses the parent graph's
|
|
212
|
+
* BitmapNeighborProvider when available for O(1) lookups with post-filter.
|
|
126
213
|
*
|
|
127
|
-
* @returns {Promise<{state: unknown, stateHash: string, adjacency: {outgoing: Map<string,
|
|
214
|
+
* @returns {Promise<{state: unknown, stateHash: string, adjacency: {outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]>}}>}
|
|
128
215
|
* @private
|
|
129
216
|
*/
|
|
130
217
|
async _materializeGraph() {
|
|
131
|
-
const materialized = await /** @type {{ _materializeGraph: () => Promise<{state: import('./JoinReducer.js').WarpStateV5, stateHash: string, adjacency: {outgoing: Map<string,
|
|
218
|
+
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
219
|
const { state, stateHash } = materialized;
|
|
133
220
|
|
|
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
|
-
}
|
|
221
|
+
/** @type {{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }} */
|
|
222
|
+
let adjacency;
|
|
145
223
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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);
|
|
224
|
+
if (materialized.provider) {
|
|
225
|
+
const visibleNodes = orsetElements(state.nodeAlive)
|
|
226
|
+
.filter((id) => matchGlob(this._matchPattern, id));
|
|
227
|
+
adjacency = await buildAdjacencyViaProvider(materialized.provider, visibleNodes);
|
|
228
|
+
} else {
|
|
229
|
+
adjacency = buildAdjacencyFromEdges(state, this._matchPattern);
|
|
176
230
|
}
|
|
177
231
|
|
|
178
|
-
return { state, stateHash, adjacency
|
|
232
|
+
return { state, stateHash, adjacency };
|
|
179
233
|
}
|
|
180
234
|
|
|
181
235
|
// ===========================================================================
|
|
@@ -260,6 +314,15 @@ export default class ObserverView {
|
|
|
260
314
|
* @returns {QueryBuilder} A query builder scoped to this observer
|
|
261
315
|
*/
|
|
262
316
|
query() {
|
|
317
|
+
/**
|
|
318
|
+
* Cast safety: QueryBuilder requires the following methods from the
|
|
319
|
+
* graph-like object it wraps:
|
|
320
|
+
* - getNodes(): Promise<string[]> (line ~680 in QueryBuilder)
|
|
321
|
+
* - getNodeProps(nodeId): Promise<Map|null> (lines ~691, ~757, ~806 in QueryBuilder)
|
|
322
|
+
* - _materializeGraph(): Promise<{adjacency, stateHash}> (line ~678 in QueryBuilder)
|
|
323
|
+
* ObserverView implements all three: getNodes() at line ~254, getNodeProps() at line ~268,
|
|
324
|
+
* _materializeGraph() at line ~214.
|
|
325
|
+
*/
|
|
263
326
|
return new QueryBuilder(/** @type {import('../WarpGraph.js').default} */ (/** @type {unknown} */ (this)));
|
|
264
327
|
}
|
|
265
328
|
}
|
|
@@ -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);
|