@git-stunts/git-warp 10.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,835 @@
1
+ /**
2
+ * QueryBuilder - Fluent query builder for materialized WARP state.
3
+ *
4
+ * Supports deterministic, multi-hop traversal over the logical graph.
5
+ */
6
+
7
+ import QueryError from '../errors/QueryError.js';
8
+
9
+ const DEFAULT_PATTERN = '*';
10
+
11
+ /**
12
+ * @typedef {Object} QueryNodeSnapshot
13
+ * @property {string} id - The unique identifier of the node
14
+ * @property {Record<string, unknown>} props - Frozen snapshot of node properties
15
+ * @property {Array<{label: string, to: string}>} edgesOut - Outgoing edges sorted by label then target
16
+ * @property {Array<{label: string, from: string}>} edgesIn - Incoming edges sorted by label then source
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} AdjacencyMaps
21
+ * @property {Map<string, Array<{label: string, neighborId: string}>>} outgoing - Map of node ID to outgoing edges
22
+ * @property {Map<string, Array<{label: string, neighborId: string}>>} incoming - Map of node ID to incoming edges
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} AggregateSpec
27
+ * @property {boolean} [count] - If true, include count of matched nodes
28
+ * @property {string} [sum] - Property path to sum (e.g., "props.price" or "price")
29
+ * @property {string} [avg] - Property path to average
30
+ * @property {string} [min] - Property path to find minimum
31
+ * @property {string} [max] - Property path to find maximum
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} QueryResult
36
+ * @property {string} stateHash - Hash of the materialized state at query time
37
+ * @property {Array<{id?: string, props?: Record<string, unknown>}>} nodes - Matched nodes (absent when aggregating)
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} AggregateResult
42
+ * @property {string} stateHash - Hash of the materialized state at query time
43
+ * @property {number} [count] - Count of matched nodes (if requested)
44
+ * @property {number} [sum] - Sum of property values (if requested)
45
+ * @property {number} [avg] - Average of property values (if requested)
46
+ * @property {number} [min] - Minimum property value (if requested)
47
+ * @property {number} [max] - Maximum property value (if requested)
48
+ */
49
+
50
+ /**
51
+ * Asserts that a match pattern is a string.
52
+ *
53
+ * @param {unknown} pattern - The pattern to validate
54
+ * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
55
+ * @private
56
+ */
57
+ function assertMatchPattern(pattern) {
58
+ if (typeof pattern !== 'string') {
59
+ throw new QueryError('match() expects a string pattern', {
60
+ code: 'E_QUERY_MATCH_TYPE',
61
+ context: { receivedType: typeof pattern },
62
+ });
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Asserts that a predicate is either a function or a plain object.
68
+ *
69
+ * @param {unknown} fn - The predicate to validate
70
+ * @throws {QueryError} If fn is neither a function nor a plain object (code: E_QUERY_WHERE_TYPE)
71
+ * @private
72
+ */
73
+ function assertPredicate(fn) {
74
+ if (typeof fn !== 'function' && !isPlainObject(fn)) {
75
+ throw new QueryError('where() expects a predicate function or object', {
76
+ code: 'E_QUERY_WHERE_TYPE',
77
+ context: { receivedType: typeof fn },
78
+ });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Checks whether a value is a plain JavaScript object (not null, not an array).
84
+ *
85
+ * @param {unknown} value - The value to check
86
+ * @returns {boolean} True if value is a non-null, non-array object
87
+ * @private
88
+ */
89
+ function isPlainObject(value) {
90
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
91
+ }
92
+
93
+ /**
94
+ * Checks whether a value is a JavaScript primitive (null, string, number, boolean, symbol, bigint, or undefined).
95
+ *
96
+ * @param {unknown} value - The value to check
97
+ * @returns {boolean} True if value is null or not an object/function
98
+ * @private
99
+ */
100
+ function isPrimitive(value) {
101
+ return value === null || (typeof value !== 'object' && typeof value !== 'function');
102
+ }
103
+
104
+ /**
105
+ * Converts a plain object to a predicate function for use in where() clauses.
106
+ *
107
+ * The returned predicate checks that all key-value pairs in the object match
108
+ * the corresponding properties in a node's props. Uses strict equality (===).
109
+ *
110
+ * @param {Record<string, unknown>} obj - Object with property constraints (all values must be primitives)
111
+ * @returns {(node: QueryNodeSnapshot) => boolean} Predicate function that returns true if all constraints match
112
+ * @throws {QueryError} If any value in obj is not a primitive (code: E_QUERY_WHERE_VALUE_TYPE)
113
+ * @private
114
+ */
115
+ function objectToPredicate(obj) {
116
+ const entries = Object.entries(obj);
117
+ for (const [key, value] of entries) {
118
+ if (!isPrimitive(value)) {
119
+ throw new QueryError(
120
+ 'where() object shorthand only accepts primitive property values',
121
+ {
122
+ code: 'E_QUERY_WHERE_VALUE_TYPE',
123
+ context: { key, receivedType: typeof value },
124
+ }
125
+ );
126
+ }
127
+ }
128
+ return ({ props }) => {
129
+ for (const [key, value] of entries) {
130
+ if (!(key in props) || props[key] !== value) {
131
+ return false;
132
+ }
133
+ }
134
+ return true;
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Asserts that an edge label is either undefined or a string.
140
+ *
141
+ * @param {unknown} label - The label to validate
142
+ * @throws {QueryError} If label is defined but not a string (code: E_QUERY_LABEL_TYPE)
143
+ * @private
144
+ */
145
+ function assertLabel(label) {
146
+ if (label === undefined) {
147
+ return;
148
+ }
149
+ if (typeof label !== 'string') {
150
+ throw new QueryError('label must be a string', {
151
+ code: 'E_QUERY_LABEL_TYPE',
152
+ context: { receivedType: typeof label },
153
+ });
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Sorts an iterable of node IDs lexicographically for deterministic output.
159
+ *
160
+ * @param {Iterable<string>} ids - The node IDs to sort
161
+ * @returns {string[]} New sorted array of IDs
162
+ * @private
163
+ */
164
+ function sortIds(ids) {
165
+ return [...ids].sort();
166
+ }
167
+
168
+ /**
169
+ * Escapes special regex characters in a string so it can be used as a literal match.
170
+ *
171
+ * @param {string} value - The string to escape
172
+ * @returns {string} The escaped string safe for use in a RegExp
173
+ * @private
174
+ */
175
+ function escapeRegex(value) {
176
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
177
+ }
178
+
179
+ /**
180
+ * Tests whether a node ID matches a glob-style pattern.
181
+ *
182
+ * Supports:
183
+ * - `*` as the default pattern, matching all node IDs
184
+ * - Wildcard `*` anywhere in the pattern, matching zero or more characters
185
+ * - Literal match when pattern contains no wildcards
186
+ *
187
+ * @param {string} nodeId - The node ID to test
188
+ * @param {string} pattern - The glob pattern (e.g., "user:*", "*:admin", "*")
189
+ * @returns {boolean} True if the node ID matches the pattern
190
+ * @private
191
+ */
192
+ function matchesPattern(nodeId, pattern) {
193
+ if (pattern === DEFAULT_PATTERN) {
194
+ return true;
195
+ }
196
+ if (pattern.includes('*')) {
197
+ const regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
198
+ return regex.test(nodeId);
199
+ }
200
+ return nodeId === pattern;
201
+ }
202
+
203
+ /**
204
+ * Recursively freezes an object and all nested objects/arrays.
205
+ *
206
+ * Already-frozen objects are skipped to avoid redundant work.
207
+ * Non-objects and null values are returned unchanged.
208
+ *
209
+ * @template T
210
+ * @param {T} obj - The object to freeze
211
+ * @returns {T} The same object, now deeply frozen
212
+ * @private
213
+ */
214
+ function deepFreeze(obj) {
215
+ if (!obj || typeof obj !== 'object' || Object.isFrozen(obj)) {
216
+ return obj;
217
+ }
218
+ Object.freeze(obj);
219
+ if (Array.isArray(obj)) {
220
+ for (const item of obj) {
221
+ deepFreeze(item);
222
+ }
223
+ } else {
224
+ for (const value of Object.values(obj)) {
225
+ deepFreeze(value);
226
+ }
227
+ }
228
+ return obj;
229
+ }
230
+
231
+ /**
232
+ * Creates a deep clone of a value.
233
+ *
234
+ * Attempts structuredClone first (Node 17+ / modern browsers), falls back
235
+ * to JSON round-trip, and returns the original value if both fail (e.g.,
236
+ * for values containing functions or circular references).
237
+ *
238
+ * Primitives are returned as-is without cloning.
239
+ *
240
+ * @template T
241
+ * @param {T} value - The value to clone
242
+ * @returns {T} A deep clone of the value, or the original if cloning fails
243
+ * @private
244
+ */
245
+ function cloneValue(value) {
246
+ if (value === null || typeof value !== 'object') {
247
+ return value;
248
+ }
249
+ if (typeof globalThis.structuredClone === 'function') {
250
+ try {
251
+ return globalThis.structuredClone(value);
252
+ } catch {
253
+ // fall through to JSON clone
254
+ }
255
+ }
256
+ try {
257
+ return JSON.parse(JSON.stringify(value));
258
+ } catch {
259
+ return value;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Builds a frozen, deterministic snapshot of node properties from a Map.
265
+ *
266
+ * Keys are sorted lexicographically for deterministic iteration order.
267
+ * Values are deep-cloned to prevent mutation of the original state.
268
+ *
269
+ * @param {Map<string, unknown>} propsMap - Map of property names to values
270
+ * @returns {Readonly<Record<string, unknown>>} Frozen object with sorted keys and cloned values
271
+ * @private
272
+ */
273
+ function buildPropsSnapshot(propsMap) {
274
+ const props = {};
275
+ const keys = [...propsMap.keys()].sort();
276
+ for (const key of keys) {
277
+ props[key] = cloneValue(propsMap.get(key));
278
+ }
279
+ return deepFreeze(props);
280
+ }
281
+
282
+ /**
283
+ * Builds a frozen, sorted snapshot of edges for a node.
284
+ *
285
+ * Edges are sorted first by label (lexicographically), then by peer node ID.
286
+ * This ensures deterministic ordering for query results.
287
+ *
288
+ * @param {Array<{label: string, neighborId?: string, to?: string, from?: string}>} edges - Array of edge objects
289
+ * @param {'to' | 'from'} directionKey - The key to use for the peer node ID in the output
290
+ * @returns {ReadonlyArray<{label: string, to?: string, from?: string}>} Frozen array of edge snapshots
291
+ * @private
292
+ */
293
+ function buildEdgesSnapshot(edges, directionKey) {
294
+ const list = edges.map((edge) => ({
295
+ label: edge.label,
296
+ [directionKey]: edge.neighborId ?? edge[directionKey],
297
+ }));
298
+ list.sort((a, b) => {
299
+ if (a.label !== b.label) {
300
+ return a.label < b.label ? -1 : 1;
301
+ }
302
+ const aPeer = a[directionKey];
303
+ const bPeer = b[directionKey];
304
+ return aPeer < bPeer ? -1 : aPeer > bPeer ? 1 : 0;
305
+ });
306
+ return deepFreeze(list);
307
+ }
308
+
309
+ /**
310
+ * Creates a complete frozen snapshot of a node for use in query predicates.
311
+ *
312
+ * The snapshot includes the node's ID, properties, outgoing edges, and incoming edges.
313
+ * All data is deeply frozen to prevent mutation.
314
+ *
315
+ * @param {Object} params - Node data
316
+ * @param {string} params.id - The node ID
317
+ * @param {Map<string, unknown>} params.propsMap - Map of property names to values
318
+ * @param {Array<{label: string, neighborId: string}>} params.edgesOut - Outgoing edges
319
+ * @param {Array<{label: string, neighborId: string}>} params.edgesIn - Incoming edges
320
+ * @returns {Readonly<QueryNodeSnapshot>} Frozen node snapshot
321
+ * @private
322
+ */
323
+ function createNodeSnapshot({ id, propsMap, edgesOut, edgesIn }) {
324
+ const props = buildPropsSnapshot(propsMap);
325
+ const edgesOutSnapshot = buildEdgesSnapshot(edgesOut, 'to');
326
+ const edgesInSnapshot = buildEdgesSnapshot(edgesIn, 'from');
327
+
328
+ return deepFreeze({
329
+ id,
330
+ props,
331
+ edgesOut: edgesOutSnapshot,
332
+ edgesIn: edgesInSnapshot,
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Normalizes a depth specification into a [min, max] tuple.
338
+ *
339
+ * Accepts:
340
+ * - undefined: defaults to [1, 1] (single hop)
341
+ * - number n: normalized to [n, n] (exactly n hops)
342
+ * - [min, max]: used as-is (range of hops)
343
+ *
344
+ * @param {number | [number, number] | undefined} depth - The depth specification
345
+ * @returns {[number, number]} Tuple of [minDepth, maxDepth]
346
+ * @throws {QueryError} If depth is not a non-negative integer (code: E_QUERY_DEPTH_TYPE)
347
+ * @throws {QueryError} If depth array values are not non-negative integers (code: E_QUERY_DEPTH_TYPE)
348
+ * @throws {QueryError} If min > max in a depth array (code: E_QUERY_DEPTH_RANGE)
349
+ * @throws {QueryError} If depth is neither a number nor a valid [min, max] array (code: E_QUERY_DEPTH_TYPE)
350
+ * @private
351
+ */
352
+ function normalizeDepth(depth) {
353
+ if (depth === undefined) {
354
+ return [1, 1];
355
+ }
356
+ if (typeof depth === 'number') {
357
+ if (!Number.isInteger(depth) || depth < 0) {
358
+ throw new QueryError('depth must be a non-negative integer', {
359
+ code: 'E_QUERY_DEPTH_TYPE',
360
+ context: { value: depth },
361
+ });
362
+ }
363
+ return [depth, depth];
364
+ }
365
+ if (Array.isArray(depth) && depth.length === 2) {
366
+ const [min, max] = depth;
367
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 0 || max < 0) {
368
+ throw new QueryError('depth values must be non-negative integers', {
369
+ code: 'E_QUERY_DEPTH_TYPE',
370
+ context: { value: depth },
371
+ });
372
+ }
373
+ if (min > max) {
374
+ throw new QueryError('depth min must be <= max', {
375
+ code: 'E_QUERY_DEPTH_RANGE',
376
+ context: { min, max },
377
+ });
378
+ }
379
+ return [min, max];
380
+ }
381
+ throw new QueryError('depth must be a number or [min, max] array', {
382
+ code: 'E_QUERY_DEPTH_TYPE',
383
+ context: { receivedType: typeof depth, value: depth },
384
+ });
385
+ }
386
+
387
+ /**
388
+ * Applies a single-hop traversal from a working set of nodes.
389
+ *
390
+ * Collects all neighbors reachable via one edge in the specified direction,
391
+ * optionally filtered by edge label.
392
+ *
393
+ * @param {Object} params - Traversal parameters
394
+ * @param {'outgoing' | 'incoming'} params.direction - Direction of traversal
395
+ * @param {string | undefined} params.label - Edge label filter (undefined = all labels)
396
+ * @param {string[]} params.workingSet - Current set of node IDs to traverse from
397
+ * @param {AdjacencyMaps} params.adjacency - Adjacency maps from materialized state
398
+ * @returns {string[]} Sorted array of neighbor node IDs
399
+ * @private
400
+ */
401
+ function applyHop({ direction, label, workingSet, adjacency }) {
402
+ const next = new Set();
403
+ const source = direction === 'outgoing' ? adjacency.outgoing : adjacency.incoming;
404
+ const labelFilter = label === undefined ? null : label;
405
+
406
+ for (const nodeId of workingSet) {
407
+ const edges = source.get(nodeId) || [];
408
+ for (const edge of edges) {
409
+ if (labelFilter && edge.label !== labelFilter) {
410
+ continue;
411
+ }
412
+ next.add(edge.neighborId);
413
+ }
414
+ }
415
+
416
+ return sortIds(next);
417
+ }
418
+
419
+ /**
420
+ * Applies a multi-hop BFS traversal from a working set of nodes.
421
+ *
422
+ * Performs breadth-first traversal up to maxDepth hops, collecting nodes
423
+ * that fall within the [minDepth, maxDepth] range. Each node is visited
424
+ * at most once (cycle-safe).
425
+ *
426
+ * If minDepth is 0, the starting nodes themselves are included in the result.
427
+ *
428
+ * @param {Object} params - Traversal parameters
429
+ * @param {'outgoing' | 'incoming'} params.direction - Direction of traversal
430
+ * @param {string | undefined} params.label - Edge label filter (undefined = all labels)
431
+ * @param {string[]} params.workingSet - Current set of node IDs to traverse from
432
+ * @param {AdjacencyMaps} params.adjacency - Adjacency maps from materialized state
433
+ * @param {[number, number]} params.depth - Tuple of [minDepth, maxDepth]
434
+ * @returns {string[]} Sorted array of reachable node IDs within the depth range
435
+ * @private
436
+ */
437
+ function applyMultiHop({ direction, label, workingSet, adjacency, depth }) {
438
+ const [minDepth, maxDepth] = depth;
439
+ const source = direction === 'outgoing' ? adjacency.outgoing : adjacency.incoming;
440
+ const labelFilter = label === undefined ? null : label;
441
+
442
+ const result = new Set();
443
+ let currentLevel = new Set(workingSet);
444
+ const visited = new Set(workingSet);
445
+
446
+ if (minDepth === 0) {
447
+ for (const nodeId of workingSet) {
448
+ result.add(nodeId);
449
+ }
450
+ }
451
+
452
+ for (let hop = 1; hop <= maxDepth; hop++) {
453
+ const nextLevel = new Set();
454
+ for (const nodeId of currentLevel) {
455
+ const edges = source.get(nodeId) || [];
456
+ for (const edge of edges) {
457
+ if (labelFilter && edge.label !== labelFilter) {
458
+ continue;
459
+ }
460
+ const neighbor = edge.neighborId;
461
+ if (visited.has(neighbor)) {
462
+ continue;
463
+ }
464
+ visited.add(neighbor);
465
+ nextLevel.add(neighbor);
466
+ if (hop >= minDepth) {
467
+ result.add(neighbor);
468
+ }
469
+ }
470
+ }
471
+ currentLevel = nextLevel;
472
+ if (currentLevel.size === 0) {
473
+ break;
474
+ }
475
+ }
476
+
477
+ return sortIds(result);
478
+ }
479
+
480
+ /**
481
+ * Fluent query builder for materialized WARP state.
482
+ *
483
+ * Supports pattern matching, predicate filtering, multi-hop traversal
484
+ * over outgoing/incoming edges, and field selection.
485
+ *
486
+ * @throws {QueryError} On invalid match patterns, where predicates, label types, or select fields
487
+ */
488
+ export default class QueryBuilder {
489
+ /**
490
+ * Creates a new QueryBuilder.
491
+ *
492
+ * @param {import('../WarpGraph.js').default} graph - The WarpGraph instance to query
493
+ */
494
+ constructor(graph) {
495
+ this._graph = graph;
496
+ this._pattern = null;
497
+ this._operations = [];
498
+ this._select = null;
499
+ this._aggregate = null;
500
+ }
501
+
502
+ /**
503
+ * Sets the match pattern for filtering nodes by ID.
504
+ *
505
+ * Supports glob-style patterns:
506
+ * - `*` matches all nodes
507
+ * - `user:*` matches all nodes starting with "user:"
508
+ * - `*:admin` matches all nodes ending with ":admin"
509
+ *
510
+ * @param {string} pattern - Glob pattern to match node IDs against
511
+ * @returns {QueryBuilder} This builder for chaining
512
+ * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
513
+ */
514
+ match(pattern) {
515
+ assertMatchPattern(pattern);
516
+ this._pattern = pattern;
517
+ return this;
518
+ }
519
+
520
+ /**
521
+ * Filters nodes by predicate function or object shorthand.
522
+ *
523
+ * Object form: `where({ role: 'admin' })` filters nodes where `props.role === 'admin'`.
524
+ * Multiple properties in the object = AND semantics.
525
+ * Function form: `where(n => n.props.age > 18)` for arbitrary predicates.
526
+ *
527
+ * @param {((node: QueryNodeSnapshot) => boolean) | Record<string, unknown>} fn - Predicate function or object with property constraints
528
+ * @returns {QueryBuilder} This builder for chaining
529
+ * @throws {QueryError} If fn is neither a function nor a plain object (code: E_QUERY_WHERE_TYPE)
530
+ * @throws {QueryError} If object shorthand contains non-primitive values (code: E_QUERY_WHERE_VALUE_TYPE)
531
+ */
532
+ where(fn) {
533
+ assertPredicate(fn);
534
+ const predicate = isPlainObject(fn) ? objectToPredicate(fn) : fn;
535
+ this._operations.push({ type: 'where', fn: predicate });
536
+ return this;
537
+ }
538
+
539
+ /**
540
+ * Traverses outgoing edges from the current working set.
541
+ *
542
+ * Replaces the working set with all nodes reachable via outgoing edges.
543
+ * Use the depth option for multi-hop traversal.
544
+ *
545
+ * @param {string} [label] - Edge label filter (undefined = all labels)
546
+ * @param {{ depth?: number | [number, number] }} [options] - Traversal options. depth can be a number (exactly N hops) or [min, max] range
547
+ * @returns {QueryBuilder} This builder for chaining
548
+ * @throws {QueryError} If called after aggregate() (code: E_QUERY_AGGREGATE_TERMINAL)
549
+ * @throws {QueryError} If label is defined but not a string (code: E_QUERY_LABEL_TYPE)
550
+ * @throws {QueryError} If depth is invalid (code: E_QUERY_DEPTH_TYPE or E_QUERY_DEPTH_RANGE)
551
+ */
552
+ outgoing(label, options) {
553
+ if (this._aggregate) {
554
+ throw new QueryError('outgoing() cannot be called after aggregate()', {
555
+ code: 'E_QUERY_AGGREGATE_TERMINAL',
556
+ });
557
+ }
558
+ assertLabel(label);
559
+ const depth = normalizeDepth(options?.depth);
560
+ this._operations.push({ type: 'outgoing', label, depth });
561
+ return this;
562
+ }
563
+
564
+ /**
565
+ * Traverses incoming edges to the current working set.
566
+ *
567
+ * Replaces the working set with all nodes that have edges pointing to nodes in the current set.
568
+ * Use the depth option for multi-hop traversal.
569
+ *
570
+ * @param {string} [label] - Edge label filter (undefined = all labels)
571
+ * @param {{ depth?: number | [number, number] }} [options] - Traversal options. depth can be a number (exactly N hops) or [min, max] range
572
+ * @returns {QueryBuilder} This builder for chaining
573
+ * @throws {QueryError} If called after aggregate() (code: E_QUERY_AGGREGATE_TERMINAL)
574
+ * @throws {QueryError} If label is defined but not a string (code: E_QUERY_LABEL_TYPE)
575
+ * @throws {QueryError} If depth is invalid (code: E_QUERY_DEPTH_TYPE or E_QUERY_DEPTH_RANGE)
576
+ */
577
+ incoming(label, options) {
578
+ if (this._aggregate) {
579
+ throw new QueryError('incoming() cannot be called after aggregate()', {
580
+ code: 'E_QUERY_AGGREGATE_TERMINAL',
581
+ });
582
+ }
583
+ assertLabel(label);
584
+ const depth = normalizeDepth(options?.depth);
585
+ this._operations.push({ type: 'incoming', label, depth });
586
+ return this;
587
+ }
588
+
589
+ /**
590
+ * Selects which fields to include in the result nodes.
591
+ *
592
+ * Available fields: `id`, `props`. If not called or called with undefined,
593
+ * all fields are included. Empty arrays behave the same as undefined.
594
+ *
595
+ * @param {string[]} [fields] - Array of field names to include (e.g., ['id', 'props'])
596
+ * @returns {QueryBuilder} This builder for chaining
597
+ * @throws {QueryError} If called after aggregate() (code: E_QUERY_AGGREGATE_TERMINAL)
598
+ * @throws {QueryError} If fields is not an array (code: E_QUERY_SELECT_TYPE)
599
+ * @throws {QueryError} If fields contains unknown field names (code: E_QUERY_SELECT_FIELD) - thrown at run() time
600
+ */
601
+ select(fields) {
602
+ if (this._aggregate) {
603
+ throw new QueryError('select() cannot be called after aggregate()', {
604
+ code: 'E_QUERY_AGGREGATE_TERMINAL',
605
+ });
606
+ }
607
+ if (fields === undefined) {
608
+ this._select = null;
609
+ return this;
610
+ }
611
+ if (!Array.isArray(fields)) {
612
+ throw new QueryError('select() expects an array of fields', {
613
+ code: 'E_QUERY_SELECT_TYPE',
614
+ context: { receivedType: typeof fields },
615
+ });
616
+ }
617
+ this._select = fields;
618
+ return this;
619
+ }
620
+
621
+ /**
622
+ * Computes aggregations over the matched nodes.
623
+ *
624
+ * This is a terminal operation - calling `select()`, `outgoing()`, or `incoming()` after
625
+ * `aggregate()` throws. The result of `run()` will contain aggregation values instead of nodes.
626
+ *
627
+ * Numeric aggregations (sum, avg, min, max) accept property paths like "price" or "nested.value".
628
+ * The "props." prefix is optional and will be stripped automatically.
629
+ *
630
+ * @param {AggregateSpec} spec - Aggregation specification
631
+ * @param {boolean} [spec.count] - If true, include count of matched nodes
632
+ * @param {string} [spec.sum] - Property path to sum
633
+ * @param {string} [spec.avg] - Property path to average
634
+ * @param {string} [spec.min] - Property path to find minimum
635
+ * @param {string} [spec.max] - Property path to find maximum
636
+ * @returns {QueryBuilder} This builder for chaining
637
+ * @throws {QueryError} If spec is not a plain object (code: E_QUERY_AGGREGATE_TYPE)
638
+ * @throws {QueryError} If numeric aggregation keys are not strings (code: E_QUERY_AGGREGATE_TYPE)
639
+ * @throws {QueryError} If count is not a boolean (code: E_QUERY_AGGREGATE_TYPE)
640
+ */
641
+ aggregate(spec) {
642
+ if (!isPlainObject(spec)) {
643
+ throw new QueryError('aggregate() expects an object', {
644
+ code: 'E_QUERY_AGGREGATE_TYPE',
645
+ context: { receivedType: typeof spec },
646
+ });
647
+ }
648
+ const numericKeys = ['sum', 'avg', 'min', 'max'];
649
+ for (const key of numericKeys) {
650
+ if (spec[key] !== undefined && typeof spec[key] !== 'string') {
651
+ throw new QueryError(`aggregate() expects ${key} to be a string path`, {
652
+ code: 'E_QUERY_AGGREGATE_TYPE',
653
+ context: { key, receivedType: typeof spec[key] },
654
+ });
655
+ }
656
+ }
657
+ if (spec.count !== undefined && typeof spec.count !== 'boolean') {
658
+ throw new QueryError('aggregate() expects count to be boolean', {
659
+ code: 'E_QUERY_AGGREGATE_TYPE',
660
+ context: { key: 'count', receivedType: typeof spec.count },
661
+ });
662
+ }
663
+ this._aggregate = spec;
664
+ return this;
665
+ }
666
+
667
+ /**
668
+ * Executes the query and returns matching nodes or aggregation results.
669
+ *
670
+ * The returned stateHash can be used to detect if the graph has changed
671
+ * between queries. Results are deterministically ordered by node ID.
672
+ *
673
+ * @returns {Promise<QueryResult | AggregateResult>} Query results with stateHash. Contains `nodes` array for regular queries, or aggregation values (count, sum, avg, min, max) if aggregate() was called.
674
+ * @throws {QueryError} If an unknown select field is specified (code: E_QUERY_SELECT_FIELD)
675
+ */
676
+ async run() {
677
+ const materialized = await this._graph._materializeGraph();
678
+ const { adjacency, stateHash } = materialized;
679
+ const allNodes = sortIds(await this._graph.getNodes());
680
+
681
+ const pattern = this._pattern ?? DEFAULT_PATTERN;
682
+
683
+ let workingSet;
684
+ workingSet = allNodes.filter((nodeId) => matchesPattern(nodeId, pattern));
685
+
686
+ for (const op of this._operations) {
687
+ if (op.type === 'where') {
688
+ const snapshots = await Promise.all(
689
+ workingSet.map(async (nodeId) => {
690
+ const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
691
+ const edgesOut = adjacency.outgoing.get(nodeId) || [];
692
+ const edgesIn = adjacency.incoming.get(nodeId) || [];
693
+ return {
694
+ nodeId,
695
+ snapshot: createNodeSnapshot({ id: nodeId, propsMap, edgesOut, edgesIn }),
696
+ };
697
+ })
698
+ );
699
+ const filtered = snapshots
700
+ .filter(({ snapshot }) => op.fn(snapshot))
701
+ .map(({ nodeId }) => nodeId);
702
+ workingSet = sortIds(filtered);
703
+ continue;
704
+ }
705
+
706
+ if (op.type === 'outgoing' || op.type === 'incoming') {
707
+ const [minD, maxD] = op.depth;
708
+ if (minD === 1 && maxD === 1) {
709
+ workingSet = applyHop({
710
+ direction: op.type,
711
+ label: op.label,
712
+ workingSet,
713
+ adjacency,
714
+ });
715
+ } else {
716
+ workingSet = applyMultiHop({
717
+ direction: op.type,
718
+ label: op.label,
719
+ workingSet,
720
+ adjacency,
721
+ depth: op.depth,
722
+ });
723
+ }
724
+ }
725
+ }
726
+
727
+ if (this._aggregate) {
728
+ return await this._runAggregate(workingSet, stateHash);
729
+ }
730
+
731
+ const selected = this._select;
732
+ const selectFields = Array.isArray(selected) && selected.length > 0 ? selected : null;
733
+ const allowedFields = new Set(['id', 'props']);
734
+ if (selectFields) {
735
+ for (const field of selectFields) {
736
+ if (!allowedFields.has(field)) {
737
+ throw new QueryError(`Unknown select field: ${field}`, {
738
+ code: 'E_QUERY_SELECT_FIELD',
739
+ context: { field },
740
+ });
741
+ }
742
+ }
743
+ }
744
+
745
+ const includeId = !selectFields || selectFields.includes('id');
746
+ const includeProps = !selectFields || selectFields.includes('props');
747
+
748
+ const nodes = await Promise.all(
749
+ workingSet.map(async (nodeId) => {
750
+ const entry = {};
751
+ if (includeId) {
752
+ entry.id = nodeId;
753
+ }
754
+ if (includeProps) {
755
+ const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
756
+ const props = buildPropsSnapshot(propsMap);
757
+ if (selectFields || Object.keys(props).length > 0) {
758
+ entry.props = props;
759
+ }
760
+ }
761
+ return entry;
762
+ })
763
+ );
764
+
765
+ return { stateHash, nodes };
766
+ }
767
+
768
+ /**
769
+ * Executes aggregate computations over the matched node set.
770
+ *
771
+ * Supports count, sum, avg, min, and max aggregations. Numeric aggregations
772
+ * (sum, avg, min, max) operate on property paths like "price" or "props.nested.value".
773
+ * Non-numeric values are silently ignored in numeric aggregations.
774
+ *
775
+ * @param {string[]} workingSet - Array of matched node IDs
776
+ * @param {string} stateHash - Hash of the materialized state
777
+ * @returns {Promise<AggregateResult>} Object containing stateHash and requested aggregation values
778
+ * @private
779
+ */
780
+ async _runAggregate(workingSet, stateHash) {
781
+ const spec = this._aggregate;
782
+ const result = { stateHash };
783
+
784
+ if (spec.count) {
785
+ result.count = workingSet.length;
786
+ }
787
+
788
+ const numericAggs = ['sum', 'avg', 'min', 'max'];
789
+ const activeAggs = numericAggs.filter((key) => spec[key]);
790
+
791
+ if (activeAggs.length > 0) {
792
+ const propsByAgg = new Map();
793
+ for (const key of activeAggs) {
794
+ propsByAgg.set(key, {
795
+ segments: spec[key].replace(/^props\./, '').split('.'),
796
+ values: [],
797
+ });
798
+ }
799
+
800
+ for (const nodeId of workingSet) {
801
+ const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map();
802
+ for (const { segments, values } of propsByAgg.values()) {
803
+ let value = propsMap.get(segments[0]);
804
+ for (let i = 1; i < segments.length; i++) {
805
+ if (value && typeof value === 'object') {
806
+ value = value[segments[i]];
807
+ } else {
808
+ value = undefined;
809
+ break;
810
+ }
811
+ }
812
+ if (typeof value === 'number' && !Number.isNaN(value)) {
813
+ values.push(value);
814
+ }
815
+ }
816
+ }
817
+
818
+ for (const [key, { values }] of propsByAgg) {
819
+ if (key === 'sum') {
820
+ result.sum = values.length > 0 ? values.reduce((a, b) => a + b, 0) : 0;
821
+ } else if (key === 'avg') {
822
+ result.avg = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
823
+ } else if (key === 'min') {
824
+ result.min =
825
+ values.length > 0 ? values.reduce((m, v) => (v < m ? v : m), Infinity) : 0;
826
+ } else if (key === 'max') {
827
+ result.max =
828
+ values.length > 0 ? values.reduce((m, v) => (v > m ? v : m), -Infinity) : 0;
829
+ }
830
+ }
831
+ }
832
+
833
+ return result;
834
+ }
835
+ }