@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.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- 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
|
+
}
|