@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,67 @@
1
+ /**
2
+ * Legacy Anchor Detector for v3 backward compatibility.
3
+ *
4
+ * This module provides functions to detect legacy v3 JSON anchors
5
+ * ({"_type":"anchor"}) alongside v4 trailer-based anchors for
6
+ * backward compatibility in E-plane traversals.
7
+ *
8
+ * @module domain/services/LegacyAnchorDetector
9
+ * @see WARP Spec Section 17 - Backward Compatibility
10
+ */
11
+
12
+ /**
13
+ * Detects if a commit message is a legacy v3 anchor.
14
+ * v3 anchors are JSON objects with _type: "anchor"
15
+ *
16
+ * @param {string} message - The commit message to check
17
+ * @returns {boolean} True if the message is a v3 JSON anchor
18
+ *
19
+ * @example
20
+ * isLegacyAnchor('{"_type":"anchor"}'); // true
21
+ * isLegacyAnchor('{"_type":"node"}'); // false
22
+ * isLegacyAnchor('plain text'); // false
23
+ */
24
+ export function isLegacyAnchor(message) {
25
+ if (typeof message !== 'string') {
26
+ return false;
27
+ }
28
+ try {
29
+ const parsed = JSON.parse(message.trim());
30
+ return parsed && parsed._type === 'anchor';
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Detects if a commit is any type of anchor (v3 JSON or v4 trailer).
38
+ *
39
+ * This function provides unified anchor detection that works across
40
+ * both protocol versions, ensuring anchors are correctly filtered
41
+ * from E-plane traversals regardless of format.
42
+ *
43
+ * @param {string} message - The commit message to check
44
+ * @returns {boolean} True if the message is any type of anchor
45
+ *
46
+ * @example
47
+ * // v4 trailer anchor
48
+ * isAnyAnchor('warp:anchor\n\neg-kind: anchor\neg-graph: test'); // true
49
+ *
50
+ * // v3 JSON anchor
51
+ * isAnyAnchor('{"_type":"anchor"}'); // true
52
+ *
53
+ * // Regular message
54
+ * isAnyAnchor('Some node content'); // false
55
+ */
56
+ export function isAnyAnchor(message) {
57
+ if (typeof message !== 'string') {
58
+ return false;
59
+ }
60
+
61
+ // Check v4 trailer-based anchor
62
+ if (message.includes('eg-kind: anchor')) {
63
+ return true;
64
+ }
65
+ // Check v3 JSON anchor
66
+ return isLegacyAnchor(message);
67
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * LogicalTraversal - Traversal utilities for the logical WARP graph.
3
+ *
4
+ * Provides deterministic BFS/DFS/shortestPath/connectedComponent over
5
+ * the materialized logical graph (node/edge OR-Sets), not the Git DAG.
6
+ */
7
+
8
+ import TraversalError from '../errors/TraversalError.js';
9
+
10
+ const DEFAULT_MAX_DEPTH = 1000;
11
+
12
+ /**
13
+ * Validates and normalizes an edge direction parameter.
14
+ *
15
+ * @param {string|undefined} direction - The direction to validate ('out', 'in', or 'both')
16
+ * @returns {'out'|'in'|'both'} The validated direction, defaulting to 'out' if undefined
17
+ * @throws {TraversalError} If the direction is not one of the valid values
18
+ */
19
+ function assertDirection(direction) {
20
+ if (direction === undefined) {
21
+ return 'out';
22
+ }
23
+ if (direction === 'out' || direction === 'in' || direction === 'both') {
24
+ return direction;
25
+ }
26
+ throw new TraversalError(`Invalid direction: ${direction}`, {
27
+ code: 'INVALID_DIRECTION',
28
+ context: { direction },
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Normalizes a label filter into a Set for efficient lookup.
34
+ *
35
+ * Accepts a single label string, an array of labels, or undefined. Returns
36
+ * a Set containing the label(s) or null if no filter is specified.
37
+ *
38
+ * @param {string|string[]|undefined} labelFilter - The label filter to normalize
39
+ * @returns {Set<string>|null} A Set of labels for filtering, or null if no filter
40
+ * @throws {TraversalError} If labelFilter is neither a string, array, nor undefined
41
+ */
42
+ function normalizeLabelFilter(labelFilter) {
43
+ if (labelFilter === undefined) {
44
+ return null;
45
+ }
46
+ if (Array.isArray(labelFilter)) {
47
+ return new Set(labelFilter);
48
+ }
49
+ if (typeof labelFilter === 'string') {
50
+ return new Set([labelFilter]);
51
+ }
52
+ throw new TraversalError('labelFilter must be a string or array', {
53
+ code: 'INVALID_LABEL_FILTER',
54
+ context: { receivedType: typeof labelFilter },
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Filters a list of neighbor edges by label.
60
+ *
61
+ * If no label set is provided (null), returns all neighbors unchanged.
62
+ * If an empty label set is provided, returns an empty array.
63
+ * Otherwise, returns only edges whose label is in the set.
64
+ *
65
+ * @param {Array<{neighborId: string, label: string}>} neighbors - The list of neighbor edges to filter
66
+ * @param {Set<string>|null} labelSet - The set of allowed labels, or null to allow all
67
+ * @returns {Array<{neighborId: string, label: string}>} The filtered list of neighbor edges
68
+ */
69
+ function filterByLabel(neighbors, labelSet) {
70
+ if (!labelSet) {
71
+ return neighbors;
72
+ }
73
+ if (labelSet.size === 0) {
74
+ return [];
75
+ }
76
+ return neighbors.filter((edge) => labelSet.has(edge.label));
77
+ }
78
+
79
+ /**
80
+ * Retrieves neighbors of a node based on direction and label filter.
81
+ *
82
+ * Returns outgoing neighbors for 'out', incoming neighbors for 'in', or
83
+ * a merged and sorted list of both for 'both'. Results are filtered by
84
+ * label if a label set is provided.
85
+ *
86
+ * For 'both' direction, neighbors are sorted first by neighborId, then by label,
87
+ * ensuring deterministic traversal order.
88
+ *
89
+ * @param {Object} params - The neighbor lookup parameters
90
+ * @param {string} params.nodeId - The node ID to get neighbors for
91
+ * @param {'out'|'in'|'both'} params.direction - The edge direction to follow
92
+ * @param {Object} params.adjacency - The adjacency structure from materialized graph
93
+ * @param {Map<string, Array<{neighborId: string, label: string}>>} params.adjacency.outgoing - Outgoing edge map
94
+ * @param {Map<string, Array<{neighborId: string, label: string}>>} params.adjacency.incoming - Incoming edge map
95
+ * @param {Set<string>|null} params.labelSet - The set of allowed labels, or null to allow all
96
+ * @returns {Array<{neighborId: string, label: string}>} The list of neighbor edges
97
+ */
98
+ function getNeighbors({ nodeId, direction, adjacency, labelSet }) {
99
+ const outgoing = filterByLabel(adjacency.outgoing.get(nodeId) || [], labelSet);
100
+ const incoming = filterByLabel(adjacency.incoming.get(nodeId) || [], labelSet);
101
+
102
+ if (direction === 'out') {
103
+ return outgoing;
104
+ }
105
+ if (direction === 'in') {
106
+ return incoming;
107
+ }
108
+
109
+ const merged = outgoing.concat(incoming);
110
+ merged.sort((a, b) => {
111
+ if (a.neighborId !== b.neighborId) {
112
+ return a.neighborId < b.neighborId ? -1 : 1;
113
+ }
114
+ return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
115
+ });
116
+ return merged;
117
+ }
118
+
119
+ /**
120
+ * Deterministic graph traversal engine for the materialized WARP graph.
121
+ *
122
+ * Provides BFS, DFS, shortest path (Dijkstra/A*), topological sort, and
123
+ * connected component algorithms over the logical node/edge OR-Sets.
124
+ * All traversals produce deterministic results via sorted neighbor ordering.
125
+ */
126
+ export default class LogicalTraversal {
127
+ /**
128
+ * Creates a new LogicalTraversal.
129
+ *
130
+ * @param {import('../WarpGraph.js').default} graph - The WarpGraph instance to traverse
131
+ */
132
+ constructor(graph) {
133
+ this._graph = graph;
134
+ }
135
+
136
+ /**
137
+ * Prepares common traversal state by materializing the graph and validating inputs.
138
+ *
139
+ * This private method is called by all traversal methods to ensure the graph is
140
+ * materialized, the start node exists, and options are normalized.
141
+ *
142
+ * @private
143
+ * @param {string} start - The starting node ID for traversal
144
+ * @param {Object} options - The traversal options to normalize
145
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
146
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
147
+ * @param {number} [options.maxDepth] - Maximum depth to traverse
148
+ * @returns {Promise<{dir: 'out'|'in'|'both', labelSet: Set<string>|null, adjacency: Object, depthLimit: number}>}
149
+ * The normalized traversal parameters
150
+ * @throws {TraversalError} If the start node is not found (NODE_NOT_FOUND)
151
+ * @throws {TraversalError} If the direction is invalid (INVALID_DIRECTION)
152
+ * @throws {TraversalError} If the labelFilter is invalid (INVALID_LABEL_FILTER)
153
+ */
154
+ async _prepare(start, { dir, labelFilter, maxDepth }) {
155
+ const materialized = await this._graph._materializeGraph();
156
+
157
+ if (!(await this._graph.hasNode(start))) {
158
+ throw new TraversalError(`Start node not found: ${start}`, {
159
+ code: 'NODE_NOT_FOUND',
160
+ context: { start },
161
+ });
162
+ }
163
+
164
+ const resolvedDir = assertDirection(dir);
165
+ const labelSet = normalizeLabelFilter(labelFilter);
166
+ const { adjacency } = materialized;
167
+ const depthLimit = maxDepth ?? DEFAULT_MAX_DEPTH;
168
+
169
+ return { dir: resolvedDir, labelSet, adjacency, depthLimit };
170
+ }
171
+
172
+ /**
173
+ * Breadth-first traversal.
174
+ *
175
+ * @param {string} start - Starting node ID
176
+ * @param {Object} [options] - Traversal options
177
+ * @param {number} [options.maxDepth] - Maximum depth to traverse
178
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
179
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
180
+ * @returns {Promise<string[]>} Node IDs in visit order
181
+ * @throws {TraversalError} If the start node is not found or direction is invalid
182
+ */
183
+ async bfs(start, options = {}) {
184
+ const { dir, labelSet, adjacency, depthLimit } = await this._prepare(start, options);
185
+ const visited = new Set();
186
+ const queue = [{ nodeId: start, depth: 0 }];
187
+ const result = [];
188
+
189
+ while (queue.length > 0) {
190
+ const current = queue.shift();
191
+ if (visited.has(current.nodeId)) {
192
+ continue;
193
+ }
194
+ if (current.depth > depthLimit) {
195
+ continue;
196
+ }
197
+
198
+ visited.add(current.nodeId);
199
+ result.push(current.nodeId);
200
+
201
+ if (current.depth === depthLimit) {
202
+ continue;
203
+ }
204
+
205
+ const neighbors = getNeighbors({
206
+ nodeId: current.nodeId,
207
+ direction: dir,
208
+ adjacency,
209
+ labelSet,
210
+ });
211
+
212
+ for (const edge of neighbors) {
213
+ if (!visited.has(edge.neighborId)) {
214
+ queue.push({ nodeId: edge.neighborId, depth: current.depth + 1 });
215
+ }
216
+ }
217
+ }
218
+
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * Depth-first traversal (pre-order).
224
+ *
225
+ * @param {string} start - Starting node ID
226
+ * @param {Object} [options] - Traversal options
227
+ * @param {number} [options.maxDepth] - Maximum depth to traverse
228
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
229
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
230
+ * @returns {Promise<string[]>} Node IDs in visit order
231
+ * @throws {TraversalError} If the start node is not found or direction is invalid
232
+ */
233
+ async dfs(start, options = {}) {
234
+ const { dir, labelSet, adjacency, depthLimit } = await this._prepare(start, options);
235
+ const visited = new Set();
236
+ const stack = [{ nodeId: start, depth: 0 }];
237
+ const result = [];
238
+
239
+ while (stack.length > 0) {
240
+ const current = stack.pop();
241
+ if (visited.has(current.nodeId)) {
242
+ continue;
243
+ }
244
+ if (current.depth > depthLimit) {
245
+ continue;
246
+ }
247
+
248
+ visited.add(current.nodeId);
249
+ result.push(current.nodeId);
250
+
251
+ if (current.depth === depthLimit) {
252
+ continue;
253
+ }
254
+
255
+ const neighbors = getNeighbors({
256
+ nodeId: current.nodeId,
257
+ direction: dir,
258
+ adjacency,
259
+ labelSet,
260
+ });
261
+
262
+ for (let i = neighbors.length - 1; i >= 0; i -= 1) {
263
+ const edge = neighbors[i];
264
+ if (!visited.has(edge.neighborId)) {
265
+ stack.push({ nodeId: edge.neighborId, depth: current.depth + 1 });
266
+ }
267
+ }
268
+ }
269
+
270
+ return result;
271
+ }
272
+
273
+ /**
274
+ * Shortest path (unweighted) using BFS.
275
+ *
276
+ * @param {string} from - Source node ID
277
+ * @param {string} to - Target node ID
278
+ * @param {Object} [options] - Traversal options
279
+ * @param {number} [options.maxDepth] - Maximum search depth
280
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
281
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
282
+ * @returns {Promise<{found: boolean, path: string[], length: number}>}
283
+ * When `found` is true, `path` contains the node IDs from `from` to `to` and
284
+ * `length` is the hop count. When `found` is false, `path` is empty and `length` is -1.
285
+ * @throws {TraversalError} If the start node is not found or direction is invalid
286
+ */
287
+ async shortestPath(from, to, options = {}) {
288
+ const { dir, labelSet, adjacency, depthLimit } = await this._prepare(from, options);
289
+
290
+ if (from === to) {
291
+ return { found: true, path: [from], length: 0 };
292
+ }
293
+
294
+ const visited = new Set();
295
+ const queue = [{ nodeId: from, depth: 0 }];
296
+ const parent = new Map();
297
+
298
+ visited.add(from);
299
+
300
+ while (queue.length > 0) {
301
+ const current = queue.shift();
302
+ if (current.depth >= depthLimit) {
303
+ continue;
304
+ }
305
+
306
+ const neighbors = getNeighbors({
307
+ nodeId: current.nodeId,
308
+ direction: dir,
309
+ adjacency,
310
+ labelSet,
311
+ });
312
+
313
+ for (const edge of neighbors) {
314
+ if (visited.has(edge.neighborId)) {
315
+ continue;
316
+ }
317
+ visited.add(edge.neighborId);
318
+ parent.set(edge.neighborId, current.nodeId);
319
+
320
+ if (edge.neighborId === to) {
321
+ const path = [to];
322
+ let cursor = current.nodeId;
323
+ while (cursor) {
324
+ path.push(cursor);
325
+ cursor = parent.get(cursor) || null;
326
+ }
327
+ path.reverse();
328
+ return { found: true, path, length: path.length - 1 };
329
+ }
330
+
331
+ queue.push({ nodeId: edge.neighborId, depth: current.depth + 1 });
332
+ }
333
+ }
334
+
335
+ return { found: false, path: [], length: -1 };
336
+ }
337
+
338
+ /**
339
+ * Connected component (undirected by default).
340
+ *
341
+ * @param {string} start - Starting node ID
342
+ * @param {Object} [options] - Traversal options
343
+ * @param {number} [options.maxDepth] - Maximum depth to traverse (default: 1000)
344
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
345
+ * @returns {Promise<string[]>} Node IDs in visit order
346
+ * @throws {TraversalError} If the start node is not found
347
+ */
348
+ async connectedComponent(start, options = {}) {
349
+ return await this.bfs(start, { ...options, dir: 'both' });
350
+ }
351
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared internals for WARP message codecs.
3
+ *
4
+ * This module provides the lazy TrailerCodec singleton, constants, and
5
+ * validation helpers used by PatchMessageCodec, CheckpointMessageCodec,
6
+ * AnchorMessageCodec, and MessageSchemaDetector.
7
+ *
8
+ * Not part of the public API — consumers should import from
9
+ * WarpMessageCodec.js (the facade) or the individual sub-codecs.
10
+ *
11
+ * @module domain/services/MessageCodecInternal
12
+ * @private
13
+ */
14
+
15
+ import { TrailerCodec, TrailerCodecService } from '@git-stunts/trailer-codec';
16
+
17
+ // -----------------------------------------------------------------------------
18
+ // Constants
19
+ // -----------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Message title prefixes for each WARP commit type.
23
+ * @type {Object<string, string>}
24
+ */
25
+ export const MESSAGE_TITLES = {
26
+ patch: 'warp:patch',
27
+ checkpoint: 'warp:checkpoint',
28
+ anchor: 'warp:anchor',
29
+ };
30
+
31
+ /**
32
+ * Standard trailer keys used across WARP messages.
33
+ * @type {Object<string, string>}
34
+ */
35
+ export const TRAILER_KEYS = {
36
+ kind: 'eg-kind',
37
+ graph: 'eg-graph',
38
+ writer: 'eg-writer',
39
+ lamport: 'eg-lamport',
40
+ patchOid: 'eg-patch-oid',
41
+ stateHash: 'eg-state-hash',
42
+ frontierOid: 'eg-frontier-oid',
43
+ indexOid: 'eg-index-oid',
44
+ schema: 'eg-schema',
45
+ checkpointVersion: 'eg-checkpoint',
46
+ };
47
+
48
+ /**
49
+ * Pattern for valid Git OIDs (40-character hex for SHA-1 or 64-character for SHA-256).
50
+ * @type {RegExp}
51
+ */
52
+ const OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
53
+
54
+ /**
55
+ * Pattern for valid SHA-256 state hashes (64-character hex).
56
+ * @type {RegExp}
57
+ */
58
+ const SHA256_PATTERN = /^[0-9a-f]{64}$/;
59
+
60
+ // -----------------------------------------------------------------------------
61
+ // Codec Instance
62
+ // -----------------------------------------------------------------------------
63
+
64
+ // Lazy singleton codec instance
65
+ let _codec = null;
66
+
67
+ /**
68
+ * Returns the lazy singleton TrailerCodec instance.
69
+ * @returns {TrailerCodec}
70
+ */
71
+ export function getCodec() {
72
+ if (!_codec) {
73
+ const service = new TrailerCodecService();
74
+ _codec = new TrailerCodec({ service });
75
+ }
76
+ return _codec;
77
+ }
78
+
79
+ // -----------------------------------------------------------------------------
80
+ // Validation Helpers
81
+ // -----------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Validates that a value is a valid Git OID.
85
+ * @param {string} oid - The OID to validate
86
+ * @param {string} fieldName - Name of the field for error messages
87
+ * @throws {Error} If the OID is invalid
88
+ */
89
+ export function validateOid(oid, fieldName) {
90
+ if (typeof oid !== 'string') {
91
+ throw new Error(`Invalid ${fieldName}: expected string, got ${typeof oid}`);
92
+ }
93
+ if (!OID_PATTERN.test(oid)) {
94
+ throw new Error(`Invalid ${fieldName}: must be a 40 or 64 character hex string, got '${oid}'`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validates that a value is a valid SHA-256 hash.
100
+ * @param {string} hash - The hash to validate
101
+ * @param {string} fieldName - Name of the field for error messages
102
+ * @throws {Error} If the hash is invalid
103
+ */
104
+ export function validateSha256(hash, fieldName) {
105
+ if (typeof hash !== 'string') {
106
+ throw new Error(`Invalid ${fieldName}: expected string, got ${typeof hash}`);
107
+ }
108
+ if (!SHA256_PATTERN.test(hash)) {
109
+ throw new Error(`Invalid ${fieldName}: must be a 64 character hex string, got '${hash}'`);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Validates that a value is a positive integer.
115
+ * @param {number} value - The value to validate
116
+ * @param {string} fieldName - Name of the field for error messages
117
+ * @throws {Error} If the value is not a positive integer
118
+ */
119
+ export function validatePositiveInteger(value, fieldName) {
120
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
121
+ throw new Error(`Invalid ${fieldName}: must be a positive integer, got ${value}`);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Validates that a schema version is valid.
127
+ * @param {number} schema - The schema version to validate
128
+ * @throws {Error} If the schema version is invalid
129
+ */
130
+ export function validateSchema(schema) {
131
+ validatePositiveInteger(schema, 'schema');
132
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Schema version detection and compatibility validation for WARP messages.
3
+ *
4
+ * Provides utilities to detect schema versions from patch operations,
5
+ * detect message kinds from raw commit messages, and validate operation
6
+ * compatibility with a reader's maximum supported schema version.
7
+ *
8
+ * See {@link module:domain/services/WarpMessageCodec} for the facade
9
+ * that re-exports all functions from this module.
10
+ *
11
+ * @module domain/services/MessageSchemaDetector
12
+ */
13
+
14
+ import { EDGE_PROP_PREFIX } from './KeyCodec.js';
15
+ import SchemaUnsupportedError from '../errors/SchemaUnsupportedError.js';
16
+ import { getCodec, TRAILER_KEYS } from './MessageCodecInternal.js';
17
+
18
+ // -----------------------------------------------------------------------------
19
+ // Constants
20
+ // -----------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Schema version for classic node-only patches (V5 format).
24
+ * @type {number}
25
+ */
26
+ export const SCHEMA_V2 = 2;
27
+
28
+ /**
29
+ * Schema version for patches that may contain edge property PropSet ops.
30
+ * @type {number}
31
+ */
32
+ export const SCHEMA_V3 = 3;
33
+
34
+ // -----------------------------------------------------------------------------
35
+ // Schema Version Detection
36
+ // -----------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Detects the appropriate schema version for a set of patch operations.
40
+ *
41
+ * Returns schema 3 if ANY PropSet op has a `node` field starting with the
42
+ * edge property prefix (`\x01`), indicating edge property support is required.
43
+ * Otherwise returns schema 2 for backward compatibility.
44
+ *
45
+ * @param {Array<{type: string, node?: string}>} ops - Array of patch operations
46
+ * @returns {number} The schema version (2 or 3)
47
+ */
48
+ export function detectSchemaVersion(ops) {
49
+ if (!Array.isArray(ops)) {
50
+ return SCHEMA_V2;
51
+ }
52
+ for (const op of ops) {
53
+ if (op.type === 'PropSet' && typeof op.node === 'string' && op.node.startsWith(EDGE_PROP_PREFIX)) {
54
+ return SCHEMA_V3;
55
+ }
56
+ }
57
+ return SCHEMA_V2;
58
+ }
59
+
60
+ // -----------------------------------------------------------------------------
61
+ // Schema Compatibility Validation
62
+ // -----------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Asserts that a set of decoded patch operations is compatible with a given
66
+ * maximum supported schema version. Throws {@link SchemaUnsupportedError} if
67
+ * any operation requires a higher schema version than `maxSchema`.
68
+ *
69
+ * Currently the only schema boundary is v2 -> v3:
70
+ * - Schema v3 introduces edge property PropSet ops (node starts with `\x01`).
71
+ * - A v2-only reader MUST reject patches containing such ops to prevent
72
+ * silent data loss.
73
+ * - A v3 patch that contains only classic node/edge ops is accepted by v2
74
+ * readers — the schema number alone is NOT a rejection criterion.
75
+ *
76
+ * @param {Array<{type: string, node?: string}>} ops - Decoded patch operations
77
+ * @param {number} maxSchema - Maximum schema version the reader supports
78
+ * @throws {SchemaUnsupportedError} If ops require a schema version > maxSchema
79
+ *
80
+ * @example
81
+ * import { assertOpsCompatible, SCHEMA_V2 } from './MessageSchemaDetector.js';
82
+ * assertOpsCompatible(patch.ops, SCHEMA_V2); // throws if edge prop ops found
83
+ */
84
+ export function assertOpsCompatible(ops, maxSchema) {
85
+ if (maxSchema >= SCHEMA_V3) {
86
+ return; // v3 readers understand everything up to v3
87
+ }
88
+ // For v2 readers: scan for edge property ops (the v3 feature)
89
+ if (!Array.isArray(ops)) {
90
+ return;
91
+ }
92
+ for (const op of ops) {
93
+ if (
94
+ op.type === 'PropSet' &&
95
+ typeof op.node === 'string' &&
96
+ op.node.startsWith(EDGE_PROP_PREFIX)
97
+ ) {
98
+ throw new SchemaUnsupportedError(
99
+ 'Upgrade to >=7.3.0 (WEIGHTED) to sync edge properties.',
100
+ {
101
+ context: {
102
+ requiredSchema: SCHEMA_V3,
103
+ maxSupportedSchema: maxSchema,
104
+ },
105
+ }
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ // -----------------------------------------------------------------------------
112
+ // Detection Helper
113
+ // -----------------------------------------------------------------------------
114
+
115
+ /**
116
+ * Detects the WARP message kind from a raw commit message.
117
+ *
118
+ * @param {string} message - The raw commit message
119
+ * @returns {'patch'|'checkpoint'|'anchor'|null} The message kind, or null if not a WARP message
120
+ *
121
+ * @example
122
+ * const kind = detectMessageKind(message);
123
+ * if (kind === 'patch') {
124
+ * const data = decodePatchMessage(message);
125
+ * }
126
+ */
127
+ export function detectMessageKind(message) {
128
+ if (typeof message !== 'string') {
129
+ return null;
130
+ }
131
+
132
+ try {
133
+ const codec = getCodec();
134
+ const decoded = codec.decode(message);
135
+ const kind = decoded.trailers[TRAILER_KEYS.kind];
136
+
137
+ if (kind === 'patch' || kind === 'checkpoint' || kind === 'anchor') {
138
+ return kind;
139
+ }
140
+ return null;
141
+ } catch {
142
+ // Not a valid message format
143
+ return null;
144
+ }
145
+ }