@git-stunts/git-warp 12.2.1 → 12.4.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 (121) hide show
  1. package/README.md +5 -5
  2. package/bin/cli/commands/info.js +1 -5
  3. package/bin/cli/infrastructure.js +6 -9
  4. package/bin/cli/shared.js +8 -0
  5. package/bin/presenters/text.js +10 -3
  6. package/bin/warp-graph.js +6 -6
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +5 -35
  9. package/src/domain/crdt/ORSet.js +3 -0
  10. package/src/domain/crdt/VersionVector.js +1 -1
  11. package/src/domain/entities/GraphNode.js +1 -6
  12. package/src/domain/errors/ForkError.js +1 -1
  13. package/src/domain/errors/IndexError.js +1 -1
  14. package/src/domain/errors/OperationAbortedError.js +1 -1
  15. package/src/domain/errors/PatchError.js +1 -1
  16. package/src/domain/errors/PersistenceError.js +45 -0
  17. package/src/domain/errors/QueryError.js +1 -1
  18. package/src/domain/errors/SchemaUnsupportedError.js +1 -1
  19. package/src/domain/errors/SyncError.js +1 -1
  20. package/src/domain/errors/TraversalError.js +1 -1
  21. package/src/domain/errors/TrustError.js +1 -1
  22. package/src/domain/errors/WormholeError.js +1 -1
  23. package/src/domain/errors/index.js +1 -0
  24. package/src/domain/services/AdjacencyNeighborProvider.js +1 -4
  25. package/src/domain/services/AnchorMessageCodec.js +1 -3
  26. package/src/domain/services/AuditMessageCodec.js +1 -5
  27. package/src/domain/services/AuditReceiptService.js +4 -18
  28. package/src/domain/services/AuditVerifierService.js +3 -7
  29. package/src/domain/services/BitmapIndexBuilder.js +6 -12
  30. package/src/domain/services/BitmapIndexReader.js +7 -20
  31. package/src/domain/services/BitmapNeighborProvider.js +1 -3
  32. package/src/domain/services/BoundaryTransitionRecord.js +7 -23
  33. package/src/domain/services/CheckpointMessageCodec.js +6 -6
  34. package/src/domain/services/CheckpointSerializerV5.js +8 -12
  35. package/src/domain/services/CheckpointService.js +28 -40
  36. package/src/domain/services/CommitDagTraversalService.js +1 -3
  37. package/src/domain/services/DagPathFinding.js +9 -59
  38. package/src/domain/services/DagTopology.js +4 -16
  39. package/src/domain/services/DagTraversal.js +7 -31
  40. package/src/domain/services/Frontier.js +4 -6
  41. package/src/domain/services/GitLogParser.js +1 -2
  42. package/src/domain/services/GraphTraversal.js +14 -114
  43. package/src/domain/services/HealthCheckService.js +3 -9
  44. package/src/domain/services/HookInstaller.js +2 -8
  45. package/src/domain/services/HttpSyncServer.js +24 -25
  46. package/src/domain/services/IncrementalIndexUpdater.js +4 -6
  47. package/src/domain/services/IndexRebuildService.js +6 -52
  48. package/src/domain/services/IndexStalenessChecker.js +2 -3
  49. package/src/domain/services/JoinReducer.js +200 -100
  50. package/src/domain/services/KeyCodec.js +48 -0
  51. package/src/domain/services/LogicalBitmapIndexBuilder.js +1 -2
  52. package/src/domain/services/LogicalIndexBuildService.js +2 -6
  53. package/src/domain/services/LogicalIndexReader.js +1 -2
  54. package/src/domain/services/LogicalTraversal.js +13 -64
  55. package/src/domain/services/MaterializedViewService.js +5 -19
  56. package/src/domain/services/MessageSchemaDetector.js +35 -5
  57. package/src/domain/services/MigrationService.js +1 -4
  58. package/src/domain/services/ObserverView.js +1 -7
  59. package/src/domain/services/OpNormalizer.js +79 -0
  60. package/src/domain/services/PatchBuilderV2.js +67 -38
  61. package/src/domain/services/PatchMessageCodec.js +1 -6
  62. package/src/domain/services/PropertyIndexBuilder.js +1 -2
  63. package/src/domain/services/PropertyIndexReader.js +1 -4
  64. package/src/domain/services/ProvenanceIndex.js +5 -7
  65. package/src/domain/services/ProvenancePayload.js +1 -1
  66. package/src/domain/services/QueryBuilder.js +3 -16
  67. package/src/domain/services/StateDiff.js +3 -9
  68. package/src/domain/services/StateSerializerV5.js +10 -10
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +13 -41
  70. package/src/domain/services/SyncAuthService.js +8 -32
  71. package/src/domain/services/SyncController.js +5 -25
  72. package/src/domain/services/SyncProtocol.js +10 -13
  73. package/src/domain/services/SyncTrustGate.js +4 -9
  74. package/src/domain/services/TemporalQuery.js +9 -27
  75. package/src/domain/services/TranslationCost.js +2 -8
  76. package/src/domain/services/WarpMessageCodec.js +2 -0
  77. package/src/domain/services/WarpStateIndexBuilder.js +2 -4
  78. package/src/domain/services/WormholeService.js +9 -25
  79. package/src/domain/trust/TrustCrypto.js +9 -10
  80. package/src/domain/trust/TrustEvaluator.js +1 -8
  81. package/src/domain/trust/TrustRecordService.js +5 -10
  82. package/src/domain/types/TickReceipt.js +9 -11
  83. package/src/domain/types/WarpTypes.js +1 -5
  84. package/src/domain/types/WarpTypesV2.js +78 -13
  85. package/src/domain/utils/CachedValue.js +1 -4
  86. package/src/domain/utils/MinHeap.js +3 -3
  87. package/src/domain/utils/RefLayout.js +26 -0
  88. package/src/domain/utils/WriterId.js +2 -7
  89. package/src/domain/utils/canonicalCbor.js +1 -1
  90. package/src/domain/utils/defaultClock.js +1 -0
  91. package/src/domain/utils/defaultCodec.js +1 -1
  92. package/src/domain/utils/parseCursorBlob.js +4 -4
  93. package/src/domain/warp/PatchSession.js +3 -8
  94. package/src/domain/warp/Writer.js +9 -12
  95. package/src/domain/warp/_wire.js +2 -2
  96. package/src/domain/warp/_wiredMethods.d.ts +5 -7
  97. package/src/domain/warp/checkpoint.methods.js +1 -1
  98. package/src/domain/warp/fork.methods.js +2 -6
  99. package/src/domain/warp/materializeAdvanced.methods.js +3 -3
  100. package/src/domain/warp/patch.methods.js +8 -8
  101. package/src/domain/warp/provenance.methods.js +5 -5
  102. package/src/domain/warp/query.methods.js +9 -18
  103. package/src/domain/warp/subscribe.methods.js +2 -8
  104. package/src/globals.d.ts +7 -0
  105. package/src/infrastructure/adapters/BunHttpAdapter.js +14 -18
  106. package/src/infrastructure/adapters/ConsoleLogger.js +2 -9
  107. package/src/infrastructure/adapters/DenoHttpAdapter.js +15 -15
  108. package/src/infrastructure/adapters/GitGraphAdapter.js +234 -58
  109. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +9 -2
  110. package/src/infrastructure/adapters/NodeHttpAdapter.js +14 -14
  111. package/src/infrastructure/adapters/WebCryptoAdapter.js +1 -2
  112. package/src/ports/BlobPort.js +2 -2
  113. package/src/ports/HttpServerPort.js +24 -2
  114. package/src/ports/RefPort.js +2 -1
  115. package/src/visualization/renderers/ascii/box.js +1 -1
  116. package/src/visualization/renderers/ascii/check.js +1 -5
  117. package/src/visualization/renderers/ascii/history.js +1 -6
  118. package/src/visualization/renderers/ascii/path.js +4 -22
  119. package/src/visualization/renderers/ascii/progress.js +1 -4
  120. package/src/visualization/renderers/ascii/seek.js +1 -5
  121. package/src/visualization/renderers/ascii/table.js +1 -3
@@ -83,10 +83,7 @@ export default class LogicalTraversal {
83
83
  * multiple starts or no start at all (topologicalSort, commonAncestors).
84
84
  *
85
85
  * @private
86
- * @param {Object} opts - The traversal options
87
- * @param {'out'|'in'|'both'} [opts.dir] - Edge direction to follow
88
- * @param {string|string[]} [opts.labelFilter] - Edge label(s) to include
89
- * @param {number} [opts.maxDepth] - Maximum depth to traverse
86
+ * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], maxDepth?: number }} opts - The traversal options
90
87
  * @returns {Promise<{engine: GraphTraversal, direction: 'out'|'in'|'both', options: {labels?: Set<string>}|undefined, depthLimit: number}>}
91
88
  * @throws {TraversalError} If the direction is invalid (INVALID_DIRECTION)
92
89
  * @throws {TraversalError} If the labelFilter is invalid (INVALID_LABEL_FILTER)
@@ -120,10 +117,7 @@ export default class LogicalTraversal {
120
117
  *
121
118
  * @private
122
119
  * @param {string} start - The starting node ID for traversal
123
- * @param {Object} opts - The traversal options
124
- * @param {'out'|'in'|'both'} [opts.dir] - Edge direction to follow
125
- * @param {string|string[]} [opts.labelFilter] - Edge label(s) to include
126
- * @param {number} [opts.maxDepth] - Maximum depth to traverse
120
+ * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], maxDepth?: number }} opts - The traversal options
127
121
  * @returns {Promise<{engine: GraphTraversal, direction: 'out'|'in'|'both', options: {labels?: Set<string>}|undefined, depthLimit: number}>}
128
122
  * @throws {TraversalError} If the start node is not found (NODE_NOT_FOUND)
129
123
  * @throws {TraversalError} If the direction is invalid (INVALID_DIRECTION)
@@ -146,10 +140,7 @@ export default class LogicalTraversal {
146
140
  * Breadth-first traversal.
147
141
  *
148
142
  * @param {string} start - Starting node ID
149
- * @param {Object} [options] - Traversal options
150
- * @param {number} [options.maxDepth] - Maximum depth to traverse
151
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
152
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
143
+ * @param {{ maxDepth?: number, dir?: 'out'|'in'|'both', labelFilter?: string|string[] }} [options] - Traversal options
153
144
  * @returns {Promise<string[]>} Node IDs in visit order
154
145
  * @throws {TraversalError} If the start node is not found or direction is invalid
155
146
  */
@@ -169,10 +160,7 @@ export default class LogicalTraversal {
169
160
  * Depth-first traversal (pre-order).
170
161
  *
171
162
  * @param {string} start - Starting node ID
172
- * @param {Object} [options] - Traversal options
173
- * @param {number} [options.maxDepth] - Maximum depth to traverse
174
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
175
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
163
+ * @param {{ maxDepth?: number, dir?: 'out'|'in'|'both', labelFilter?: string|string[] }} [options] - Traversal options
176
164
  * @returns {Promise<string[]>} Node IDs in visit order
177
165
  * @throws {TraversalError} If the start node is not found or direction is invalid
178
166
  */
@@ -193,10 +181,7 @@ export default class LogicalTraversal {
193
181
  *
194
182
  * @param {string} from - Source node ID
195
183
  * @param {string} to - Target node ID
196
- * @param {Object} [options] - Traversal options
197
- * @param {number} [options.maxDepth] - Maximum search depth
198
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
199
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
184
+ * @param {{ maxDepth?: number, dir?: 'out'|'in'|'both', labelFilter?: string|string[] }} [options] - Traversal options
200
185
  * @returns {Promise<{found: boolean, path: string[], length: number}>}
201
186
  * When `found` is true, `path` contains the node IDs from `from` to `to` and
202
187
  * `length` is the hop count. When `found` is false, `path` is empty and `length` is -1.
@@ -219,9 +204,7 @@ export default class LogicalTraversal {
219
204
  * Connected component (undirected by default).
220
205
  *
221
206
  * @param {string} start - Starting node ID
222
- * @param {Object} [options] - Traversal options
223
- * @param {number} [options.maxDepth] - Maximum depth to traverse (default: 1000)
224
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
207
+ * @param {{ maxDepth?: number, labelFilter?: string|string[] }} [options] - Traversal options
225
208
  * @returns {Promise<string[]>} Node IDs in visit order
226
209
  * @throws {TraversalError} If the start node is not found
227
210
  */
@@ -236,11 +219,7 @@ export default class LogicalTraversal {
236
219
  *
237
220
  * @param {string} from - Source node ID
238
221
  * @param {string} to - Target node ID
239
- * @param {Object} [options] - Traversal options
240
- * @param {number} [options.maxDepth] - Maximum search depth
241
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
242
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
243
- * @param {AbortSignal} [options.signal] - Abort signal
222
+ * @param {{ maxDepth?: number, dir?: 'out'|'in'|'both', labelFilter?: string|string[], signal?: AbortSignal }} [options] - Traversal options
244
223
  * @returns {Promise<{reachable: boolean}>}
245
224
  */
246
225
  async isReachable(from, to, options = {}) {
@@ -262,12 +241,7 @@ export default class LogicalTraversal {
262
241
  *
263
242
  * @param {string} from - Source node ID
264
243
  * @param {string} to - Target node ID
265
- * @param {Object} [options] - Traversal options
266
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
267
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
268
- * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
269
- * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
270
- * @param {AbortSignal} [options.signal] - Abort signal
244
+ * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], weightFn?: (from: string, to: string, label: string) => number | Promise<number>, nodeWeightFn?: (nodeId: string) => number | Promise<number>, signal?: AbortSignal }} [options] - Traversal options
271
245
  * @returns {Promise<{path: string[], totalCost: number}>}
272
246
  * @throws {TraversalError} code 'NO_PATH' if unreachable
273
247
  * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
@@ -292,13 +266,7 @@ export default class LogicalTraversal {
292
266
  *
293
267
  * @param {string} from - Source node ID
294
268
  * @param {string} to - Target node ID
295
- * @param {Object} [options] - Traversal options
296
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
297
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
298
- * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
299
- * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
300
- * @param {(nodeId: string, goalId: string) => number} [options.heuristicFn] - Heuristic function
301
- * @param {AbortSignal} [options.signal] - Abort signal
269
+ * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], weightFn?: (from: string, to: string, label: string) => number | Promise<number>, nodeWeightFn?: (nodeId: string) => number | Promise<number>, heuristicFn?: (nodeId: string, goalId: string) => number, signal?: AbortSignal }} [options] - Traversal options
302
270
  * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number}>}
303
271
  * @throws {TraversalError} code 'NO_PATH' if unreachable
304
272
  * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
@@ -326,13 +294,7 @@ export default class LogicalTraversal {
326
294
  *
327
295
  * @param {string} from - Source node ID
328
296
  * @param {string} to - Target node ID
329
- * @param {Object} [options] - Traversal options
330
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
331
- * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
332
- * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
333
- * @param {(nodeId: string, goalId: string) => number} [options.forwardHeuristic] - Forward heuristic
334
- * @param {(nodeId: string, goalId: string) => number} [options.backwardHeuristic] - Backward heuristic
335
- * @param {AbortSignal} [options.signal] - Abort signal
297
+ * @param {{ labelFilter?: string|string[], weightFn?: (from: string, to: string, label: string) => number | Promise<number>, nodeWeightFn?: (nodeId: string) => number | Promise<number>, forwardHeuristic?: (nodeId: string, goalId: string) => number, backwardHeuristic?: (nodeId: string, goalId: string) => number, signal?: AbortSignal }} [options] - Traversal options
336
298
  * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number}>}
337
299
  * @throws {TraversalError} code 'NO_PATH' if unreachable
338
300
  * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
@@ -365,11 +327,7 @@ export default class LogicalTraversal {
365
327
  * Topological sort (Kahn's algorithm).
366
328
  *
367
329
  * @param {string|string[]} start - One or more start nodes
368
- * @param {Object} [options] - Traversal options
369
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
370
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
371
- * @param {boolean} [options.throwOnCycle] - Whether to throw on cycle detection
372
- * @param {AbortSignal} [options.signal] - Abort signal
330
+ * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], throwOnCycle?: boolean, signal?: AbortSignal }} [options] - Traversal options
373
331
  * @returns {Promise<{sorted: string[], hasCycle: boolean}>}
374
332
  * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if throwOnCycle and cycle found
375
333
  * @throws {TraversalError} code 'NODE_NOT_FOUND' if a start node does not exist
@@ -405,11 +363,7 @@ export default class LogicalTraversal {
405
363
  * Direction is fixed to 'in' (backward BFS).
406
364
  *
407
365
  * @param {string[]} nodes - Nodes to find common ancestors of
408
- * @param {Object} [options] - Traversal options
409
- * @param {number} [options.maxDepth] - Maximum search depth
410
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
411
- * @param {number} [options.maxResults] - Maximum number of results
412
- * @param {AbortSignal} [options.signal] - Abort signal
366
+ * @param {{ maxDepth?: number, labelFilter?: string|string[], maxResults?: number, signal?: AbortSignal }} [options] - Traversal options
413
367
  * @returns {Promise<{ancestors: string[]}>}
414
368
  * @throws {TraversalError} code 'NODE_NOT_FOUND' if a node does not exist
415
369
  */
@@ -443,12 +397,7 @@ export default class LogicalTraversal {
443
397
  *
444
398
  * @param {string} from - Source node ID
445
399
  * @param {string} to - Target node ID
446
- * @param {Object} [options] - Traversal options
447
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
448
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
449
- * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
450
- * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
451
- * @param {AbortSignal} [options.signal] - Abort signal
400
+ * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], weightFn?: (from: string, to: string, label: string) => number | Promise<number>, nodeWeightFn?: (nodeId: string) => number | Promise<number>, signal?: AbortSignal }} [options] - Traversal options
452
401
  * @returns {Promise<{path: string[], totalCost: number}>}
453
402
  * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if graph has cycles
454
403
  * @throws {TraversalError} code 'NO_PATH' if unreachable
@@ -207,11 +207,7 @@ function canonicalizeNeighborSignatures(edges) {
207
207
  /**
208
208
  * Compares bitmap index neighbors against ground-truth adjacency for one node.
209
209
  *
210
- * @param {Object} params
211
- * @param {string} params.nodeId
212
- * @param {string} params.direction
213
- * @param {LogicalIndex} params.logicalIndex
214
- * @param {Map<string, Array<{neighborId: string, label: string}>>} params.truthMap
210
+ * @param {{ nodeId: string, direction: string, logicalIndex: LogicalIndex, truthMap: Map<string, Array<{neighborId: string, label: string}>> }} params
215
211
  * @returns {VerifyError|null}
216
212
  */
217
213
  function compareNodeDirection({ nodeId, direction, logicalIndex, truthMap }) {
@@ -232,9 +228,7 @@ function compareNodeDirection({ nodeId, direction, logicalIndex, truthMap }) {
232
228
 
233
229
  export default class MaterializedViewService {
234
230
  /**
235
- * @param {Object} [options]
236
- * @param {import('../../ports/CodecPort.js').default} [options.codec]
237
- * @param {import('../../ports/LoggerPort.js').default} [options.logger]
231
+ * @param {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} [options]
238
232
  */
239
233
  constructor({ codec, logger } = {}) {
240
234
  this._codec = codec || defaultCodec;
@@ -308,10 +302,7 @@ export default class MaterializedViewService {
308
302
  /**
309
303
  * Applies a PatchDiff incrementally to an existing index tree.
310
304
  *
311
- * @param {Object} params
312
- * @param {Record<string, Uint8Array>} params.existingTree
313
- * @param {import('../types/PatchDiff.js').PatchDiff} params.diff
314
- * @param {import('./JoinReducer.js').WarpStateV5} params.state
305
+ * @param {{ existingTree: Record<string, Uint8Array>, diff: import('../types/PatchDiff.js').PatchDiff, state: import('./JoinReducer.js').WarpStateV5 }} params
315
306
  * @returns {BuildResult}
316
307
  */
317
308
  applyDiff({ existingTree, diff, state }) {
@@ -345,16 +336,11 @@ export default class MaterializedViewService {
345
336
  * Verifies index integrity by sampling alive nodes and comparing
346
337
  * bitmap neighbor queries against adjacency-based ground truth.
347
338
  *
348
- * @param {Object} params
349
- * @param {import('./JoinReducer.js').WarpStateV5} params.state
350
- * @param {LogicalIndex} params.logicalIndex
351
- * @param {Object} [params.options]
352
- * @param {number} [params.options.seed] - PRNG seed for reproducible sampling
353
- * @param {number} [params.options.sampleRate] - Fraction of nodes to check (>0 and <=1, default 0.1)
339
+ * @param {{ state: import('./JoinReducer.js').WarpStateV5, logicalIndex: LogicalIndex, options?: { seed?: number, sampleRate?: number } }} params
354
340
  * @returns {VerifyResult}
355
341
  */
356
342
  verifyIndex({ state, logicalIndex, options = {} }) {
357
- const seed = options.seed ?? (Date.now() & 0x7FFFFFFF);
343
+ const seed = options.seed ?? (Math.random() * 0x7FFFFFFF >>> 0);
358
344
  const sampleRate = options.sampleRate ?? 0.1;
359
345
  const allNodes = [...orsetElements(state.nodeAlive)].sort();
360
346
  const sampled = sampleNodes(allNodes, sampleRate, seed);
@@ -20,17 +20,33 @@ import { getCodec, TRAILER_KEYS } from './MessageCodecInternal.js';
20
20
  // -----------------------------------------------------------------------------
21
21
 
22
22
  /**
23
- * Schema version for classic node-only patches (V5 format).
23
+ * Patch schema version for classic node-only patches (V5 format).
24
24
  * @type {number}
25
25
  */
26
26
  export const SCHEMA_V2 = 2;
27
27
 
28
28
  /**
29
- * Schema version for patches that may contain edge property PropSet ops.
29
+ * Patch schema version for patches that may contain edge property PropSet ops.
30
30
  * @type {number}
31
31
  */
32
32
  export const SCHEMA_V3 = 3;
33
33
 
34
+ /**
35
+ * Alias: patch schema v2 (classic node-only patches).
36
+ * Use this when you need to be explicit that you mean *patch* schema,
37
+ * not checkpoint schema.
38
+ * @type {number}
39
+ */
40
+ export const PATCH_SCHEMA_V2 = SCHEMA_V2;
41
+
42
+ /**
43
+ * Alias: patch schema v3 (edge-property-aware patches).
44
+ * Use this when you need to be explicit that you mean *patch* schema,
45
+ * not checkpoint schema.
46
+ * @type {number}
47
+ */
48
+ export const PATCH_SCHEMA_V3 = SCHEMA_V3;
49
+
34
50
  // -----------------------------------------------------------------------------
35
51
  // Schema Version Detection
36
52
  // -----------------------------------------------------------------------------
@@ -50,6 +66,14 @@ export function detectSchemaVersion(ops) {
50
66
  return SCHEMA_V2;
51
67
  }
52
68
  for (const op of ops) {
69
+ if (!op || typeof op !== 'object') {
70
+ continue;
71
+ }
72
+ // Canonical EdgePropSet always implies schema 3
73
+ if (op.type === 'EdgePropSet') {
74
+ return SCHEMA_V3;
75
+ }
76
+ // Legacy raw PropSet with edge-property encoding
53
77
  if (op.type === 'PropSet' && typeof op.node === 'string' && op.node.startsWith(EDGE_PROP_PREFIX)) {
54
78
  return SCHEMA_V3;
55
79
  }
@@ -90,10 +114,16 @@ export function assertOpsCompatible(ops, maxSchema) {
90
114
  return;
91
115
  }
92
116
  for (const op of ops) {
117
+ if (!op || typeof op !== 'object') {
118
+ continue;
119
+ }
93
120
  if (
94
- op.type === 'PropSet' &&
95
- typeof op.node === 'string' &&
96
- op.node.startsWith(EDGE_PROP_PREFIX)
121
+ // Canonical EdgePropSet (ADR 1) — should never appear on wire pre-ADR 2,
122
+ // but reject defensively for v2 readers
123
+ op.type === 'EdgePropSet' ||
124
+ (op.type === 'PropSet' &&
125
+ typeof op.node === 'string' &&
126
+ op.node.startsWith(EDGE_PROP_PREFIX))
97
127
  ) {
98
128
  throw new SchemaUnsupportedError(
99
129
  'Upgrade to >=7.3.0 (WEIGHTED) to sync edge properties.',
@@ -13,10 +13,7 @@ import { createVersionVector, vvIncrement } from '../crdt/VersionVector.js';
13
13
  * writer, rebuilds the ORSet structures, and copies only properties belonging
14
14
  * to visible nodes (dropping dangling props from deleted nodes).
15
15
  *
16
- * @param {Object} v4State - The V4 materialized state (visible projection)
17
- * @param {Map<string, {value: boolean}>} v4State.nodeAlive - V4 node alive map
18
- * @param {Map<string, {value: boolean}>} v4State.edgeAlive - V4 edge alive map
19
- * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} v4State.prop - V4 property map
16
+ * @param {{ nodeAlive: Map<string, {value: boolean}>, edgeAlive: Map<string, {value: boolean}>, prop: Map<string, import('../crdt/LWW.js').LWWRegister<unknown>> }} v4State - The V4 materialized state (visible projection)
20
17
  * @param {string} migrationWriterId - Writer ID to use for synthetic dots
21
18
  * @returns {import('./JoinReducer.js').WarpStateV5} The migrated V5 state
22
19
  */
@@ -156,13 +156,7 @@ export default class ObserverView {
156
156
  /**
157
157
  * Creates a new ObserverView.
158
158
  *
159
- * @param {Object} options
160
- * @param {string} options.name - Observer name
161
- * @param {Object} options.config - Observer configuration
162
- * @param {string|string[]} options.config.match - Glob pattern(s) for visible nodes
163
- * @param {string[]} [options.config.expose] - Property keys to include
164
- * @param {string[]} [options.config.redact] - Property keys to exclude (takes precedence over expose)
165
- * @param {import('../WarpGraph.js').default} options.graph - The source WarpGraph instance
159
+ * @param {{ name: string, config: { match: string|string[], expose?: string[], redact?: string[] }, graph: import('../WarpGraph.js').default }} options
166
160
  */
167
161
  constructor({ name, config, graph }) {
168
162
  /** @type {string} */
@@ -0,0 +1,79 @@
1
+ /**
2
+ * OpNormalizer — raw ↔ canonical operation conversion.
3
+ *
4
+ * ADR 1 (Canonicalize Edge Property Operations Internally) requires that
5
+ * reducers, provenance, receipts, and queries operate on canonical ops:
6
+ *
7
+ * Raw (persisted): NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet, BlobValue
8
+ * Canonical (internal): NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, NodePropSet, EdgePropSet, BlobValue
9
+ *
10
+ * **Current normalization location:** Normalization is performed at the
11
+ * reducer entry points (`applyFast`, `applyWithReceipt`, `applyWithDiff`
12
+ * in JoinReducer.js), not at the CBOR decode boundary as originally
13
+ * planned in ADR 1. This is a pragmatic deviation — the reducer calls
14
+ * `normalizeRawOp()` on each op before dispatch. Lowering happens in
15
+ * `PatchBuilderV2.build()`/`commit()` via `lowerCanonicalOp()`.
16
+ *
17
+ * @module domain/services/OpNormalizer
18
+ */
19
+
20
+ import { createNodePropSetV2, createEdgePropSetV2, createPropSetV2 } from '../types/WarpTypesV2.js';
21
+ import { isLegacyEdgePropNode, decodeLegacyEdgePropNode, encodeLegacyEdgePropNode } from './KeyCodec.js';
22
+
23
+ /**
24
+ * Normalizes a single raw (persisted) op into its canonical form.
25
+ *
26
+ * - Raw `PropSet` with \x01-prefixed node → canonical `EdgePropSet`
27
+ * - Raw `PropSet` without prefix → canonical `NodePropSet`
28
+ * - All other op types pass through unchanged.
29
+ *
30
+ * @param {import('../types/WarpTypesV2.js').RawOpV2 | {type: string}} rawOp
31
+ * @returns {import('../types/WarpTypesV2.js').CanonicalOpV2 | {type: string}}
32
+ */
33
+ export function normalizeRawOp(rawOp) {
34
+ if (!rawOp || typeof rawOp !== 'object' || typeof rawOp.type !== 'string') {
35
+ return rawOp;
36
+ }
37
+ if (rawOp.type !== 'PropSet') {
38
+ return rawOp;
39
+ }
40
+ const op = /** @type {import('../types/WarpTypesV2.js').OpV2PropSet} */ (rawOp);
41
+ if (isLegacyEdgePropNode(op.node)) {
42
+ const { from, to, label } = decodeLegacyEdgePropNode(op.node);
43
+ return createEdgePropSetV2(from, to, label, op.key, op.value);
44
+ }
45
+ return createNodePropSetV2(op.node, op.key, op.value);
46
+ }
47
+
48
+ /**
49
+ * Lowers a single canonical op back to raw (persisted) form.
50
+ *
51
+ * - Canonical `NodePropSet` → raw `PropSet`
52
+ * - Canonical `EdgePropSet` → raw `PropSet` with legacy \x01-prefixed node
53
+ * - All other op types pass through unchanged.
54
+ *
55
+ * In M13, this always produces legacy raw PropSet for property ops.
56
+ * A future graph capability cutover (ADR 2) may allow emitting raw
57
+ * `EdgePropSet` directly.
58
+ *
59
+ * @param {import('../types/WarpTypesV2.js').CanonicalOpV2 | {type: string}} canonicalOp
60
+ * @returns {import('../types/WarpTypesV2.js').RawOpV2 | {type: string}}
61
+ */
62
+ export function lowerCanonicalOp(canonicalOp) {
63
+ switch (canonicalOp.type) {
64
+ case 'NodePropSet': {
65
+ const op = /** @type {import('../types/WarpTypesV2.js').OpV2NodePropSet} */ (canonicalOp);
66
+ return createPropSetV2(op.node, op.key, op.value);
67
+ }
68
+ case 'EdgePropSet': {
69
+ const op = /** @type {import('../types/WarpTypesV2.js').OpV2EdgePropSet} */ (canonicalOp);
70
+ return createPropSetV2(
71
+ encodeLegacyEdgePropNode(op.from, op.to, op.label),
72
+ op.key,
73
+ op.value,
74
+ );
75
+ }
76
+ default:
77
+ return canonicalOp;
78
+ }
79
+ }
@@ -20,10 +20,12 @@ import {
20
20
  createNodeRemoveV2,
21
21
  createEdgeAddV2,
22
22
  createEdgeRemoveV2,
23
- createPropSetV2,
23
+ createNodePropSetV2,
24
+ createEdgePropSetV2,
24
25
  createPatchV2,
25
26
  } from '../types/WarpTypesV2.js';
26
- import { encodeEdgeKey, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
27
+ import { encodeEdgeKey, FIELD_SEPARATOR, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
28
+ import { lowerCanonicalOp } from './OpNormalizer.js';
27
29
  import { encodePatchMessage, decodePatchMessage, detectMessageKind } from './WarpMessageCodec.js';
28
30
  import { buildWriterRef } from '../utils/RefLayout.js';
29
31
  import WriterError from '../errors/WriterError.js';
@@ -66,6 +68,30 @@ function findAttachedData(state, nodeId) {
66
68
  return { edges, props, hasData: edges.length > 0 || props.length > 0 };
67
69
  }
68
70
 
71
+ /**
72
+ * Validates that an identifier does not contain reserved bytes that would
73
+ * make the legacy edge-property encoding ambiguous.
74
+ *
75
+ * Rejects:
76
+ * - Identifiers containing \0 (field separator)
77
+ * - Identifiers starting with \x01 (edge property prefix)
78
+ *
79
+ * @param {string} value - Identifier to validate
80
+ * @param {string} label - Human-readable label for error messages
81
+ * @throws {Error} If the identifier contains reserved bytes
82
+ */
83
+ function _assertNoReservedBytes(value, label) {
84
+ if (typeof value !== 'string') {
85
+ throw new Error(`${label} must be a string, got ${typeof value}`);
86
+ }
87
+ if (value.includes(FIELD_SEPARATOR)) {
88
+ throw new Error(`${label} must not contain null bytes (\\0): ${JSON.stringify(value)}`);
89
+ }
90
+ if (value.length > 0 && value[0] === EDGE_PROP_PREFIX) {
91
+ throw new Error(`${label} must not start with reserved prefix \\x01: ${JSON.stringify(value)}`);
92
+ }
93
+ }
94
+
69
95
  /**
70
96
  * Fluent builder for creating WARP v5 patches with dots and observed-remove semantics.
71
97
  */
@@ -73,19 +99,7 @@ export class PatchBuilderV2 {
73
99
  /**
74
100
  * Creates a new PatchBuilderV2.
75
101
  *
76
- * @param {Object} options
77
- * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git adapter
78
- * (uses CommitPort + RefPort + BlobPort + TreePort methods)
79
- * @param {string} options.graphName - Graph namespace
80
- * @param {string} options.writerId - This writer's ID
81
- * @param {number} options.lamport - Lamport timestamp for this patch
82
- * @param {import('../crdt/VersionVector.js').VersionVector} options.versionVector - Current version vector
83
- * @param {Function} options.getCurrentState - Function that returns the current materialized state
84
- * @param {string|null} [options.expectedParentSha] - Expected parent SHA for race detection
85
- * @param {Function|null} [options.onCommitSuccess] - Callback invoked after successful commit
86
- * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
87
- * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
88
- * @param {{ warn: Function }} [options.logger] - Logger for non-fatal warnings
102
+ * @param {{ persistence: import('../../ports/GraphPersistencePort.js').default, graphName: string, writerId: string, lamport: number, versionVector: import('../crdt/VersionVector.js').VersionVector, getCurrentState: () => import('../services/JoinReducer.js').WarpStateV5 | null, expectedParentSha?: string|null, onCommitSuccess?: ((result: {patch: import('../types/WarpTypesV2.js').PatchV2, sha: string}) => void | Promise<void>)|null, onDeleteWithData?: 'reject'|'cascade'|'warn', codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} options
89
103
  */
90
104
  constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
91
105
  /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} */
@@ -103,8 +117,8 @@ export class PatchBuilderV2 {
103
117
  /** @type {import('../crdt/VersionVector.js').VersionVector} */
104
118
  this._vv = vvClone(versionVector); // Clone to track local increments
105
119
 
106
- /** @type {Function} */
107
- this._getCurrentState = getCurrentState; // Function to get current materialized state
120
+ /** @type {() => import('../services/JoinReducer.js').WarpStateV5 | null} */
121
+ this._getCurrentState = getCurrentState;
108
122
 
109
123
  /**
110
124
  * Snapshot of state captured at construction time (C4).
@@ -133,7 +147,7 @@ export class PatchBuilderV2 {
133
147
  /** @type {import('../../ports/CodecPort.js').default} */
134
148
  this._codec = codec || defaultCodec;
135
149
 
136
- /** @type {{ warn: Function }} */
150
+ /** @type {import('../../ports/LoggerPort.js').default} */
137
151
  this._logger = logger || nullLogger;
138
152
 
139
153
  /**
@@ -233,6 +247,7 @@ export class PatchBuilderV2 {
233
247
  */
234
248
  addNode(nodeId) {
235
249
  this._assertNotCommitted();
250
+ _assertNoReservedBytes(nodeId, 'nodeId');
236
251
  const dot = vvIncrement(this._vv, this._writerId);
237
252
  this._ops.push(createNodeAddV2(nodeId, dot));
238
253
  // Provenance: NodeAdd writes the node
@@ -305,8 +320,7 @@ export class PatchBuilderV2 {
305
320
  }
306
321
 
307
322
  if (this._onDeleteWithData === 'warn') {
308
- // eslint-disable-next-line no-console
309
- console.warn(
323
+ this._logger.warn(
310
324
  `[warp] Deleting node '${nodeId}' which has attached data (${summary}). ` +
311
325
  `Orphaned data will remain in state.`
312
326
  );
@@ -349,6 +363,9 @@ export class PatchBuilderV2 {
349
363
  */
350
364
  addEdge(from, to, label) {
351
365
  this._assertNotCommitted();
366
+ _assertNoReservedBytes(from, 'from node ID');
367
+ _assertNoReservedBytes(to, 'to node ID');
368
+ _assertNoReservedBytes(label, 'edge label');
352
369
  const dot = vvIncrement(this._vv, this._writerId);
353
370
  this._ops.push(createEdgeAddV2(from, to, label, dot));
354
371
  const edgeKey = encodeEdgeKey(from, to, label);
@@ -428,9 +445,11 @@ export class PatchBuilderV2 {
428
445
  */
429
446
  setProperty(nodeId, key, value) {
430
447
  this._assertNotCommitted();
431
- // Props don't use dots - they use EventId from patch context
432
- this._ops.push(createPropSetV2(nodeId, key, value));
433
- // Provenance: PropSet reads the node (implicit existence check) and writes the node
448
+ _assertNoReservedBytes(nodeId, 'nodeId');
449
+ _assertNoReservedBytes(key, 'property key');
450
+ // Canonical NodePropSet lowered to raw PropSet at commit time
451
+ this._ops.push(createNodePropSetV2(nodeId, key, value));
452
+ // Provenance: NodePropSet reads the node (implicit existence check) and writes the node
434
453
  this._observedOperands.add(nodeId);
435
454
  this._writes.add(nodeId);
436
455
  return this;
@@ -475,6 +494,10 @@ export class PatchBuilderV2 {
475
494
  */
476
495
  setEdgeProperty(from, to, label, key, value) {
477
496
  this._assertNotCommitted();
497
+ _assertNoReservedBytes(from, 'from node ID');
498
+ _assertNoReservedBytes(to, 'to node ID');
499
+ _assertNoReservedBytes(label, 'edge label');
500
+ _assertNoReservedBytes(key, 'property key');
478
501
  // Validate edge exists in this patch or in current state
479
502
  const ek = encodeEdgeKey(from, to, label);
480
503
  if (!this._edgesAdded.has(ek)) {
@@ -484,15 +507,10 @@ export class PatchBuilderV2 {
484
507
  }
485
508
  }
486
509
 
487
- // Encode the edge identity as the "node" field with the \x01 prefix.
488
- // When JoinReducer processes: encodePropKey(op.node, op.key)
489
- // = `\x01from\0to\0label` + `\0` + key
490
- // = `\x01from\0to\0label\0key`
491
- // = encodeEdgePropKey(from, to, label, key)
492
- const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
493
- this._ops.push(createPropSetV2(edgeNode, key, value));
510
+ // Canonical EdgePropSet lowered to legacy raw PropSet at commit time
511
+ this._ops.push(createEdgePropSetV2(from, to, label, key, value));
494
512
  this._hasEdgeProps = true;
495
- // Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
513
+ // Provenance: EdgePropSet reads the edge (implicit existence check) and writes the edge
496
514
  this._observedOperands.add(ek);
497
515
  this._writes.add(ek);
498
516
  return this;
@@ -510,11 +528,14 @@ export class PatchBuilderV2 {
510
528
  * only sets the `_content` property — it does not create the node.
511
529
  *
512
530
  * @param {string} nodeId - The node ID to attach content to
513
- * @param {Buffer|string} content - The content to attach
531
+ * @param {Uint8Array|string} content - The content to attach
514
532
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
515
533
  */
516
534
  async attachContent(nodeId, content) {
517
535
  this._assertNotCommitted();
536
+ // Validate identifiers before writing blob to avoid orphaned blobs
537
+ _assertNoReservedBytes(nodeId, 'nodeId');
538
+ _assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
518
539
  const oid = await this._persistence.writeBlob(content);
519
540
  this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
520
541
  this._contentBlobs.push(oid);
@@ -528,11 +549,16 @@ export class PatchBuilderV2 {
528
549
  * @param {string} from - Source node ID
529
550
  * @param {string} to - Target node ID
530
551
  * @param {string} label - Edge label
531
- * @param {Buffer|string} content - The content to attach
552
+ * @param {Uint8Array|string} content - The content to attach
532
553
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
533
554
  */
534
555
  async attachEdgeContent(from, to, label, content) {
535
556
  this._assertNotCommitted();
557
+ // Validate identifiers before writing blob to avoid orphaned blobs
558
+ _assertNoReservedBytes(from, 'from');
559
+ _assertNoReservedBytes(to, 'to');
560
+ _assertNoReservedBytes(label, 'label');
561
+ _assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
536
562
  const oid = await this._persistence.writeBlob(content);
537
563
  this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
538
564
  this._contentBlobs.push(oid);
@@ -559,12 +585,14 @@ export class PatchBuilderV2 {
559
585
  */
560
586
  build() {
561
587
  const schema = this._hasEdgeProps ? 3 : 2;
588
+ // Lower canonical ops to raw form for the persisted patch
589
+ const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
562
590
  return createPatchV2({
563
591
  schema,
564
592
  writer: this._writerId,
565
593
  lamport: this._lamport,
566
594
  context: vvSerialize(this._vv),
567
- ops: this._ops,
595
+ ops: rawOps,
568
596
  reads: [...this._observedOperands].sort(),
569
597
  writes: [...this._writes].sort(),
570
598
  });
@@ -678,20 +706,21 @@ export class PatchBuilderV2 {
678
706
  // For now, we use the calculated lamport for the patch metadata.
679
707
  // The dots themselves are independent of patch lamport (they use VV counters).
680
708
  const schema = this._hasEdgeProps ? 3 : 2;
681
- // Use createPatchV2 for consistent patch construction (DRY with build())
709
+ // Lower canonical ops to raw form for the persisted patch
710
+ const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
682
711
  const patch = createPatchV2({
683
712
  schema,
684
713
  writer: this._writerId,
685
714
  lamport,
686
715
  context: vvSerialize(this._vv),
687
- ops: this._ops,
716
+ ops: rawOps,
688
717
  reads: [...this._observedOperands].sort(),
689
718
  writes: [...this._writes].sort(),
690
719
  });
691
720
 
692
721
  // 6. Encode patch as CBOR and write as a Git blob
693
722
  const patchCbor = this._codec.encode(patch);
694
- const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
723
+ const patchBlobOid = await this._persistence.writeBlob(patchCbor);
695
724
 
696
725
  // 7. Create tree with the patch blob + any content blobs (deduplicated)
697
726
  // Format for mktree: "mode type oid\tpath"
@@ -726,7 +755,7 @@ export class PatchBuilderV2 {
726
755
  await this._onCommitSuccess({ patch, sha: newCommitSha });
727
756
  } catch (err) {
728
757
  // Commit is already persisted — log but don't fail the caller.
729
- this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
758
+ this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, { error: err });
730
759
  }
731
760
  }
732
761
 
@@ -25,12 +25,7 @@ import {
25
25
  /**
26
26
  * Encodes a patch commit message.
27
27
  *
28
- * @param {Object} options - The patch message options
29
- * @param {string} options.graph - The graph name
30
- * @param {string} options.writer - The writer ID
31
- * @param {number} options.lamport - The Lamport timestamp (must be a positive integer)
32
- * @param {string} options.patchOid - The OID of the patch blob
33
- * @param {number} [options.schema=2] - The schema version (defaults to 2 for new messages)
28
+ * @param {{ graph: string, writer: string, lamport: number, patchOid: string, schema?: number }} options - The patch message options
34
29
  * @returns {string} The encoded commit message
35
30
  * @throws {Error} If any validation fails
36
31
  *