@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,669 @@
1
+ /**
2
+ * PatchBuilderV2 - Fluent API for building WARP v5 (schema:2) patches.
3
+ *
4
+ * Key differences from PatchBuilder:
5
+ * 1. Maintains a VersionVector per writer
6
+ * 2. Assigns dots on add operations using vvIncrement
7
+ * 3. Reads current state to populate observedDots for removes
8
+ * 4. Includes context VersionVector in patch
9
+ *
10
+ * @module domain/services/PatchBuilderV2
11
+ * @see WARP v5 Spec
12
+ */
13
+
14
+ import defaultCodec from '../utils/defaultCodec.js';
15
+ import nullLogger from '../utils/nullLogger.js';
16
+ import { vvIncrement, vvClone, vvSerialize } from '../crdt/VersionVector.js';
17
+ import { orsetGetDots, orsetContains, orsetElements } from '../crdt/ORSet.js';
18
+ import {
19
+ createNodeAddV2,
20
+ createNodeRemoveV2,
21
+ createEdgeAddV2,
22
+ createEdgeRemoveV2,
23
+ createPropSetV2,
24
+ createPatchV2,
25
+ } from '../types/WarpTypesV2.js';
26
+ import { encodeEdgeKey, EDGE_PROP_PREFIX } from './KeyCodec.js';
27
+ import { encodePatchMessage, decodePatchMessage } from './WarpMessageCodec.js';
28
+ import { buildWriterRef } from '../utils/RefLayout.js';
29
+ import WriterError from '../errors/WriterError.js';
30
+
31
+ /**
32
+ * Inspects materialized state for edges and properties attached to a node.
33
+ *
34
+ * Used internally by `removeNode` to detect attached data before deletion.
35
+ * When a node has connected edges or properties, the builder can reject,
36
+ * warn, or cascade delete based on the `onDeleteWithData` policy.
37
+ *
38
+ * @param {import('./JoinReducer.js').WarpStateV5} state - Materialized state to inspect
39
+ * @param {string} nodeId - Node ID to check for attached data
40
+ * @returns {{ edges: string[], props: string[], hasData: boolean }} Object containing:
41
+ * - `edges`: Array of encoded edge keys (`from\0to\0label`) connected to this node
42
+ * - `props`: Array of property keys (`nodeId\0key`) belonging to this node
43
+ * - `hasData`: Boolean indicating whether any edges or properties are attached
44
+ */
45
+ function findAttachedData(state, nodeId) {
46
+ const edges = [];
47
+ const props = [];
48
+
49
+ for (const key of orsetElements(state.edgeAlive)) {
50
+ const parts = key.split('\0');
51
+ if (parts[0] === nodeId || parts[1] === nodeId) {
52
+ edges.push(key);
53
+ }
54
+ }
55
+
56
+ const propPrefix = `${nodeId}\0`;
57
+ for (const key of state.prop.keys()) {
58
+ if (key.startsWith(propPrefix)) {
59
+ props.push(key);
60
+ }
61
+ }
62
+
63
+ return { edges, props, hasData: edges.length > 0 || props.length > 0 };
64
+ }
65
+
66
+ /**
67
+ * Fluent builder for creating WARP v5 patches with dots and observed-remove semantics.
68
+ */
69
+ export class PatchBuilderV2 {
70
+ /**
71
+ * Creates a new PatchBuilderV2.
72
+ *
73
+ * @param {Object} options
74
+ * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git adapter
75
+ * (uses CommitPort + RefPort + BlobPort + TreePort methods)
76
+ * @param {string} options.graphName - Graph namespace
77
+ * @param {string} options.writerId - This writer's ID
78
+ * @param {number} options.lamport - Lamport timestamp for this patch
79
+ * @param {import('../crdt/VersionVector.js').VersionVector} options.versionVector - Current version vector
80
+ * @param {Function} options.getCurrentState - Function that returns the current materialized state
81
+ * @param {string|null} [options.expectedParentSha] - Expected parent SHA for race detection
82
+ * @param {Function|null} [options.onCommitSuccess] - Callback invoked after successful commit
83
+ * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
84
+ * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
85
+ * @param {{ warn: Function }} [options.logger] - Logger for non-fatal warnings
86
+ */
87
+ constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
88
+ /** @type {import('../../ports/GraphPersistencePort.js').default} */
89
+ this._persistence = persistence;
90
+
91
+ /** @type {string} */
92
+ this._graphName = graphName;
93
+
94
+ /** @type {string} */
95
+ this._writerId = writerId;
96
+
97
+ /** @type {number} */
98
+ this._lamport = lamport;
99
+
100
+ /** @type {import('../crdt/VersionVector.js').VersionVector} */
101
+ this._vv = vvClone(versionVector); // Clone to track local increments
102
+
103
+ /** @type {Function} */
104
+ this._getCurrentState = getCurrentState; // Function to get current materialized state
105
+
106
+ /** @type {string|null} */
107
+ this._expectedParentSha = expectedParentSha;
108
+
109
+ /** @type {Function|null} */
110
+ this._onCommitSuccess = onCommitSuccess;
111
+
112
+ /** @type {import('../types/WarpTypesV2.js').OpV2[]} */
113
+ this._ops = [];
114
+
115
+ /** @type {Set<string>} Edge keys added in this patch (for setEdgeProperty validation) */
116
+ this._edgesAdded = new Set();
117
+
118
+ /** @type {'reject'|'cascade'|'warn'} */
119
+ this._onDeleteWithData = onDeleteWithData;
120
+
121
+ /** @type {import('../../ports/CodecPort.js').default} */
122
+ this._codec = codec || defaultCodec;
123
+
124
+ /** @type {{ warn: Function }} */
125
+ this._logger = logger || nullLogger;
126
+
127
+ /**
128
+ * Nodes/edges read by this patch (for provenance tracking).
129
+ *
130
+ * Design note: "reads" track observed-dot dependencies — entities whose
131
+ * state was consulted to build this patch. Remove operations read the
132
+ * entity to observe its dots (OR-Set semantics). "Writes" track new data
133
+ * creation (adds). This distinction enables finer-grained provenance
134
+ * queries like "which patches wrote to X?" vs "which patches depended on X?"
135
+ *
136
+ * @type {Set<string>}
137
+ */
138
+ this._reads = new Set();
139
+
140
+ /**
141
+ * Nodes/edges written by this patch (for provenance tracking).
142
+ *
143
+ * Writes represent new data creation: NodeAdd writes the node, EdgeAdd
144
+ * writes the edge key, PropSet writes the node. Remove operations are
145
+ * intentionally tracked only as reads (see _reads comment above).
146
+ *
147
+ * @type {Set<string>}
148
+ */
149
+ this._writes = new Set();
150
+ }
151
+
152
+ /**
153
+ * Adds a node to the graph.
154
+ *
155
+ * Generates a new dot (version vector increment) for the add operation,
156
+ * enabling proper OR-Set semantics. The dot uniquely identifies this
157
+ * add event for later observed-remove operations.
158
+ *
159
+ * @param {string} nodeId - The node ID to add. Should be unique within the graph.
160
+ * Convention: use namespaced IDs like `'user:alice'` or `'doc:123'`.
161
+ * @returns {PatchBuilderV2} This builder instance for method chaining
162
+ *
163
+ * @example
164
+ * builder.addNode('user:alice');
165
+ *
166
+ * @example
167
+ * // Chained with other operations
168
+ * builder
169
+ * .addNode('user:alice')
170
+ * .addNode('user:bob')
171
+ * .addEdge('user:alice', 'user:bob', 'follows');
172
+ */
173
+ addNode(nodeId) {
174
+ const dot = vvIncrement(this._vv, this._writerId);
175
+ this._ops.push(createNodeAddV2(nodeId, dot));
176
+ // Provenance: NodeAdd writes the node
177
+ this._writes.add(nodeId);
178
+ return this;
179
+ }
180
+
181
+ /**
182
+ * Removes a node from the graph.
183
+ *
184
+ * Reads observed dots from the current materialized state to enable proper
185
+ * OR-Set removal semantics. The removal only affects add events that have
186
+ * been observed at the time of removal; concurrent adds will survive.
187
+ *
188
+ * Behavior when the node has attached data (edges or properties) is controlled
189
+ * by the `onDeleteWithData` constructor option:
190
+ * - `'reject'`: Throws an error, preventing the deletion
191
+ * - `'cascade'`: Automatically generates `removeEdge` operations for all connected edges
192
+ * - `'warn'` (default): Logs a warning but allows the deletion, leaving orphaned data
193
+ *
194
+ * @param {string} nodeId - The node ID to remove
195
+ * @returns {PatchBuilderV2} This builder instance for method chaining
196
+ * @throws {Error} When `onDeleteWithData` is `'reject'` and the node has attached
197
+ * edges or properties. Error message includes counts of attached data.
198
+ *
199
+ * @example
200
+ * builder.removeNode('user:alice');
201
+ *
202
+ * @example
203
+ * // With cascade mode enabled in constructor
204
+ * const builder = graph.createPatch({ onDeleteWithData: 'cascade' });
205
+ * builder.removeNode('user:alice'); // Also removes all connected edges
206
+ */
207
+ removeNode(nodeId) {
208
+ // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
209
+ const state = this._getCurrentState();
210
+
211
+ // Cascade mode: auto-generate EdgeRemove ops for all connected edges before NodeRemove.
212
+ // Generated ops appear in the patch for auditability.
213
+ if (this._onDeleteWithData === 'cascade' && state) {
214
+ const { edges } = findAttachedData(state, nodeId);
215
+ for (const edgeKey of edges) {
216
+ const [from, to, label] = edgeKey.split('\0');
217
+ const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)];
218
+ this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots));
219
+ // Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots)
220
+ this._reads.add(edgeKey);
221
+ }
222
+ }
223
+
224
+ // Best-effort delete-guard validation at build time (reject/warn modes)
225
+ if (state && this._onDeleteWithData !== 'cascade') {
226
+ const { edges, props, hasData } = findAttachedData(state, nodeId);
227
+ if (hasData) {
228
+ const details = [];
229
+ if (edges.length > 0) {
230
+ details.push(`${edges.length} edge(s)`);
231
+ }
232
+ if (props.length > 0) {
233
+ details.push(`${props.length} propert${props.length === 1 ? 'y' : 'ies'}`);
234
+ }
235
+ const summary = details.join(' and ');
236
+
237
+ if (this._onDeleteWithData === 'reject') {
238
+ throw new Error(
239
+ `Cannot delete node '${nodeId}': node has attached data (${summary}). ` +
240
+ `Remove edges and properties first, or set onDeleteWithData to 'cascade'.`
241
+ );
242
+ }
243
+
244
+ if (this._onDeleteWithData === 'warn') {
245
+ // eslint-disable-next-line no-console
246
+ console.warn(
247
+ `[warp] Deleting node '${nodeId}' which has attached data (${summary}). ` +
248
+ `Orphaned data will remain in state.`
249
+ );
250
+ }
251
+ }
252
+ }
253
+
254
+ const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [];
255
+ this._ops.push(createNodeRemoveV2(nodeId, observedDots));
256
+ // Provenance: NodeRemove reads the node (to observe its dots)
257
+ this._reads.add(nodeId);
258
+ return this;
259
+ }
260
+
261
+ /**
262
+ * Adds a directed edge between two nodes.
263
+ *
264
+ * Generates a new dot (version vector increment) for the add operation,
265
+ * enabling proper OR-Set semantics. The edge is identified by the triple
266
+ * `(from, to, label)`, allowing multiple edges between the same nodes
267
+ * with different labels.
268
+ *
269
+ * Note: This does not validate that the source and target nodes exist.
270
+ * Edges can reference nodes that will be added later in the same patch
271
+ * or that exist in the materialized state.
272
+ *
273
+ * @param {string} from - Source node ID (edge origin)
274
+ * @param {string} to - Target node ID (edge destination)
275
+ * @param {string} label - Edge label/type describing the relationship
276
+ * @returns {PatchBuilderV2} This builder instance for method chaining
277
+ *
278
+ * @example
279
+ * builder.addEdge('user:alice', 'user:bob', 'follows');
280
+ *
281
+ * @example
282
+ * // Multiple edges between same nodes with different labels
283
+ * builder
284
+ * .addEdge('user:alice', 'user:bob', 'follows')
285
+ * .addEdge('user:alice', 'user:bob', 'collaborates_with');
286
+ */
287
+ addEdge(from, to, label) {
288
+ const dot = vvIncrement(this._vv, this._writerId);
289
+ this._ops.push(createEdgeAddV2(from, to, label, dot));
290
+ const edgeKey = encodeEdgeKey(from, to, label);
291
+ this._edgesAdded.add(edgeKey);
292
+ // Provenance: EdgeAdd reads both endpoint nodes, writes the edge key
293
+ this._reads.add(from);
294
+ this._reads.add(to);
295
+ this._writes.add(edgeKey);
296
+ return this;
297
+ }
298
+
299
+ /**
300
+ * Removes a directed edge between two nodes.
301
+ *
302
+ * Reads observed dots from the current materialized state to enable proper
303
+ * OR-Set removal semantics. The removal only affects add events that have
304
+ * been observed at the time of removal; concurrent adds will survive.
305
+ *
306
+ * The edge is identified by the exact triple `(from, to, label)`. Removing
307
+ * an edge that doesn't exist is a no-op (the removal will have no observed
308
+ * dots and will not affect the materialized state).
309
+ *
310
+ * @param {string} from - Source node ID (edge origin)
311
+ * @param {string} to - Target node ID (edge destination)
312
+ * @param {string} label - Edge label/type describing the relationship
313
+ * @returns {PatchBuilderV2} This builder instance for method chaining
314
+ *
315
+ * @example
316
+ * builder.removeEdge('user:alice', 'user:bob', 'follows');
317
+ *
318
+ * @example
319
+ * // Remove edge before removing connected nodes
320
+ * builder
321
+ * .removeEdge('user:alice', 'user:bob', 'follows')
322
+ * .removeNode('user:alice');
323
+ */
324
+ removeEdge(from, to, label) {
325
+ // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
326
+ const state = this._getCurrentState();
327
+ const edgeKey = encodeEdgeKey(from, to, label);
328
+ const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
329
+ this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
330
+ // Provenance: EdgeRemove reads the edge key (to observe its dots)
331
+ this._reads.add(edgeKey);
332
+ return this;
333
+ }
334
+
335
+ /**
336
+ * Sets a property on a node.
337
+ *
338
+ * Properties use Last-Write-Wins (LWW) semantics ordered by EventId
339
+ * (lamport timestamp, then writer ID, then patch SHA). Unlike node/edge
340
+ * operations which use OR-Set dots, properties are simple registers
341
+ * where the latest write wins deterministically.
342
+ *
343
+ * Note: This does not validate that the node exists. Properties can be
344
+ * set on nodes that will be added later in the same patch or that exist
345
+ * in the materialized state.
346
+ *
347
+ * @param {string} nodeId - The node ID to set the property on
348
+ * @param {string} key - Property key (should not contain null bytes)
349
+ * @param {*} value - Property value. Must be JSON-serializable (strings,
350
+ * numbers, booleans, arrays, plain objects, or null). Use `null` to
351
+ * effectively delete a property (LWW semantics).
352
+ * @returns {PatchBuilderV2} This builder instance for method chaining
353
+ *
354
+ * @example
355
+ * builder.setProperty('user:alice', 'name', 'Alice');
356
+ *
357
+ * @example
358
+ * // Set multiple properties on the same node
359
+ * builder
360
+ * .setProperty('user:alice', 'name', 'Alice')
361
+ * .setProperty('user:alice', 'email', 'alice@example.com')
362
+ * .setProperty('user:alice', 'age', 30);
363
+ */
364
+ setProperty(nodeId, key, value) {
365
+ // Props don't use dots - they use EventId from patch context
366
+ this._ops.push(createPropSetV2(nodeId, key, value));
367
+ // Provenance: PropSet reads the node (implicit existence check) and writes the node
368
+ this._reads.add(nodeId);
369
+ this._writes.add(nodeId);
370
+ return this;
371
+ }
372
+
373
+ /**
374
+ * Sets a property on an edge.
375
+ *
376
+ * Properties use Last-Write-Wins (LWW) semantics ordered by EventId
377
+ * (lamport timestamp, then writer ID, then patch SHA). The edge is
378
+ * identified by the triple `(from, to, label)`.
379
+ *
380
+ * Internally, edge properties are stored using a special encoding with
381
+ * the `\x01` prefix to distinguish them from node properties. The
382
+ * JoinReducer processes them using the canonical edge property key format.
383
+ *
384
+ * Unlike `setProperty`, this method validates that the edge exists either
385
+ * in the current patch (added via `addEdge`) or in the materialized state.
386
+ * This prevents setting properties on edges that don't exist.
387
+ *
388
+ * @param {string} from - Source node ID (edge origin)
389
+ * @param {string} to - Target node ID (edge destination)
390
+ * @param {string} label - Edge label/type identifying which edge to modify
391
+ * @param {string} key - Property key (should not contain null bytes)
392
+ * @param {*} value - Property value. Must be JSON-serializable (strings,
393
+ * numbers, booleans, arrays, plain objects, or null). Use `null` to
394
+ * effectively delete a property (LWW semantics).
395
+ * @returns {PatchBuilderV2} This builder instance for method chaining
396
+ * @throws {Error} When the edge `(from, to, label)` does not exist in
397
+ * either this patch or the current materialized state. Message format:
398
+ * `"Cannot set property on unknown edge (from -> to [label]): add the edge first"`
399
+ *
400
+ * @example
401
+ * builder.setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01');
402
+ *
403
+ * @example
404
+ * // Add edge and set property in the same patch
405
+ * builder
406
+ * .addEdge('user:alice', 'user:bob', 'follows')
407
+ * .setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01')
408
+ * .setEdgeProperty('user:alice', 'user:bob', 'follows', 'public', true);
409
+ */
410
+ setEdgeProperty(from, to, label, key, value) {
411
+ // Validate edge exists in this patch or in current state
412
+ const ek = encodeEdgeKey(from, to, label);
413
+ if (!this._edgesAdded.has(ek)) {
414
+ const state = this._getCurrentState();
415
+ if (!state || !orsetContains(state.edgeAlive, ek)) {
416
+ throw new Error(`Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`);
417
+ }
418
+ }
419
+
420
+ // Encode the edge identity as the "node" field with the \x01 prefix.
421
+ // When JoinReducer processes: encodePropKey(op.node, op.key)
422
+ // = `\x01from\0to\0label` + `\0` + key
423
+ // = `\x01from\0to\0label\0key`
424
+ // = encodeEdgePropKey(from, to, label, key)
425
+ const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
426
+ this._ops.push(createPropSetV2(edgeNode, key, value));
427
+ // Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
428
+ this._reads.add(ek);
429
+ this._writes.add(ek);
430
+ return this;
431
+ }
432
+
433
+ /**
434
+ * Builds the PatchV2 object without committing.
435
+ *
436
+ * This method constructs the patch structure from all queued operations.
437
+ * The patch includes the schema version (2 or 3 depending on whether edge
438
+ * properties are present), writer ID, lamport timestamp, version vector
439
+ * context, and all operations.
440
+ *
441
+ * Note: This method is primarily for testing and inspection. For normal
442
+ * usage, prefer `commit()` which builds and persists the patch atomically.
443
+ *
444
+ * @returns {import('../types/WarpTypesV2.js').PatchV2} The constructed patch object containing:
445
+ * - `schema`: Version number (2 for node/edge ops, 3 if edge properties present)
446
+ * - `writer`: Writer ID string
447
+ * - `lamport`: Lamport timestamp for ordering
448
+ * - `context`: Version vector for causal context
449
+ * - `ops`: Array of operations (NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet)
450
+ */
451
+ build() {
452
+ const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
453
+ return createPatchV2({
454
+ schema,
455
+ writer: this._writerId,
456
+ lamport: this._lamport,
457
+ context: this._vv,
458
+ ops: this._ops,
459
+ reads: [...this._reads].sort(),
460
+ writes: [...this._writes].sort(),
461
+ });
462
+ }
463
+
464
+ /**
465
+ * Commits the patch to the graph.
466
+ *
467
+ * This method performs the following steps atomically:
468
+ * 1. Validates the patch is non-empty
469
+ * 2. Checks for concurrent modifications (compare-and-swap on writer ref)
470
+ * 3. Calculates the next lamport timestamp from the parent commit
471
+ * 4. Encodes the patch as CBOR and writes it as a Git blob
472
+ * 5. Creates a Git tree containing the patch blob
473
+ * 6. Creates a commit with proper trailers linking to the parent
474
+ * 7. Updates the writer ref to point to the new commit
475
+ * 8. Invokes the success callback if provided (for eager re-materialization)
476
+ *
477
+ * The commit is written to the writer's patch chain at:
478
+ * `refs/warp/<graphName>/writers/<writerId>`
479
+ *
480
+ * @returns {Promise<string>} The commit SHA of the new patch commit
481
+ * @throws {Error} If the patch is empty (no operations were added).
482
+ * Message: `"Cannot commit empty patch: no operations added"`
483
+ * @throws {WriterError} If a concurrent commit was detected (another process
484
+ * advanced the writer ref since this builder was created). Error has
485
+ * `code: 'WRITER_CAS_CONFLICT'` and properties `expectedSha`, `actualSha`.
486
+ * Recovery: call `graph.materialize()` and retry with a new builder.
487
+ *
488
+ * @example
489
+ * const sha = await builder
490
+ * .addNode('user:alice')
491
+ * .setProperty('user:alice', 'name', 'Alice')
492
+ * .addEdge('user:alice', 'user:bob', 'follows')
493
+ * .commit();
494
+ * console.log(`Committed patch: ${sha}`);
495
+ *
496
+ * @example
497
+ * // Handling concurrent modification
498
+ * try {
499
+ * await builder.commit();
500
+ * } catch (err) {
501
+ * if (err.code === 'WRITER_CAS_CONFLICT') {
502
+ * await graph.materialize(); // Refresh state
503
+ * // Retry with new builder...
504
+ * }
505
+ * }
506
+ */
507
+ async commit() {
508
+ // 1. Reject empty patches
509
+ if (this._ops.length === 0) {
510
+ throw new Error('Cannot commit empty patch: no operations added');
511
+ }
512
+
513
+ // 2. Race detection: check if writer ref has advanced since builder creation
514
+ const writerRef = buildWriterRef(this._graphName, this._writerId);
515
+ const currentRefSha = await this._persistence.readRef(writerRef);
516
+
517
+ if (currentRefSha !== this._expectedParentSha) {
518
+ const err = new WriterError(
519
+ 'WRITER_CAS_CONFLICT',
520
+ 'Commit failed: writer ref was updated by another process. Re-materialize and retry.'
521
+ );
522
+ err.expectedSha = this._expectedParentSha;
523
+ err.actualSha = currentRefSha;
524
+ throw err;
525
+ }
526
+
527
+ // 3. Calculate lamport and parent from current ref state
528
+ let lamport = 1;
529
+ let parentCommit = null;
530
+
531
+ if (currentRefSha) {
532
+ // Read the current patch commit to get its lamport timestamp
533
+ const commitMessage = await this._persistence.showNode(currentRefSha);
534
+ const patchInfo = decodePatchMessage(commitMessage);
535
+ lamport = patchInfo.lamport + 1;
536
+ parentCommit = currentRefSha;
537
+ }
538
+
539
+ // 3. Build PatchV2 structure with correct lamport
540
+ // Note: Dots were assigned using constructor lamport, but commit lamport may differ.
541
+ // For now, we use the calculated lamport for the patch metadata.
542
+ // The dots themselves are independent of patch lamport (they use VV counters).
543
+ const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
544
+ // Use createPatchV2 for consistent patch construction (DRY with build())
545
+ const patch = createPatchV2({
546
+ schema,
547
+ writer: this._writerId,
548
+ lamport,
549
+ context: vvSerialize(this._vv),
550
+ ops: this._ops,
551
+ reads: [...this._reads].sort(),
552
+ writes: [...this._writes].sort(),
553
+ });
554
+
555
+ // 4. Encode patch as CBOR
556
+ const patchCbor = this._codec.encode(patch);
557
+
558
+ // 5. Write patch.cbor blob
559
+ const patchBlobOid = await this._persistence.writeBlob(patchCbor);
560
+
561
+ // 6. Create tree with the blob
562
+ // Format for mktree: "mode type oid\tpath"
563
+ const treeEntry = `100644 blob ${patchBlobOid}\tpatch.cbor`;
564
+ const treeOid = await this._persistence.writeTree([treeEntry]);
565
+
566
+ // 7. Create patch commit message with trailers (schema:2)
567
+ const commitMessage = encodePatchMessage({
568
+ graph: this._graphName,
569
+ writer: this._writerId,
570
+ lamport,
571
+ patchOid: patchBlobOid,
572
+ schema,
573
+ });
574
+
575
+ // 8. Create commit with tree, linking to previous patch as parent if exists
576
+ const parents = parentCommit ? [parentCommit] : [];
577
+ const newCommitSha = await this._persistence.commitNodeWithTree({
578
+ treeOid,
579
+ parents,
580
+ message: commitMessage,
581
+ });
582
+
583
+ // 9. Update writer ref to point to new commit
584
+ await this._persistence.updateRef(writerRef, newCommitSha);
585
+
586
+ // 10. Notify success callback (updates graph's version vector + eager re-materialize)
587
+ if (this._onCommitSuccess) {
588
+ try {
589
+ await this._onCommitSuccess({ patch, sha: newCommitSha });
590
+ } catch (err) {
591
+ // Commit is already persisted — log but don't fail the caller.
592
+ this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
593
+ }
594
+ }
595
+
596
+ // 11. Return the new commit SHA
597
+ return newCommitSha;
598
+ }
599
+
600
+ /**
601
+ * Gets the operations array.
602
+ *
603
+ * Returns the internal array of operations queued in this builder.
604
+ * Useful for inspection and testing. Modifying the returned array
605
+ * will affect the builder's state.
606
+ *
607
+ * @returns {import('../types/WarpTypesV2.js').OpV2[]} Array of operations, each being one of:
608
+ * - `NodeAdd`: `{ type: 'NodeAdd', id, dot }`
609
+ * - `NodeRemove`: `{ type: 'NodeRemove', id, observed }`
610
+ * - `EdgeAdd`: `{ type: 'EdgeAdd', from, to, label, dot }`
611
+ * - `EdgeRemove`: `{ type: 'EdgeRemove', from, to, label, observed }`
612
+ * - `PropSet`: `{ type: 'PropSet', node, key, value }`
613
+ */
614
+ get ops() {
615
+ return this._ops;
616
+ }
617
+
618
+ /**
619
+ * Gets the current version vector (with local increments).
620
+ *
621
+ * Returns the builder's version vector, which is cloned from the graph's
622
+ * version vector at construction time and then incremented for each
623
+ * `addNode` and `addEdge` operation. This tracks the causal context
624
+ * for OR-Set dot generation.
625
+ *
626
+ * @returns {import('../crdt/VersionVector.js').VersionVector} The version vector as a
627
+ * `Map<string, number>` mapping writer IDs to their logical clock values
628
+ */
629
+ get versionVector() {
630
+ return this._vv;
631
+ }
632
+
633
+ /**
634
+ * Gets the set of node/edge IDs read by this patch.
635
+ *
636
+ * Returns a frozen copy of the reads tracked for provenance. This includes:
637
+ * - Nodes read via `removeNode` (to observe dots)
638
+ * - Endpoint nodes read via `addEdge` (implicit existence reference)
639
+ * - Edge keys read via `removeEdge` (to observe dots)
640
+ * - Nodes read via `setProperty` (implicit existence reference)
641
+ * - Edge keys read via `setEdgeProperty` (implicit existence reference)
642
+ *
643
+ * Note: Returns a defensive copy to prevent external mutation of internal state.
644
+ * The returned Set is a copy, so mutations to it do not affect the builder.
645
+ *
646
+ * @returns {ReadonlySet<string>} Copy of node IDs and encoded edge keys that were read
647
+ */
648
+ get reads() {
649
+ return new Set(this._reads);
650
+ }
651
+
652
+ /**
653
+ * Gets the set of node/edge IDs written by this patch.
654
+ *
655
+ * Returns a copy of the writes tracked for provenance. This includes:
656
+ * - Nodes written via `addNode`
657
+ * - Edge keys written via `addEdge`
658
+ * - Nodes written via `setProperty`
659
+ * - Edge keys written via `setEdgeProperty`
660
+ *
661
+ * Note: Returns a defensive copy to prevent external mutation of internal state.
662
+ * The returned Set is a copy, so mutations to it do not affect the builder.
663
+ *
664
+ * @returns {ReadonlySet<string>} Copy of node IDs and encoded edge keys that were written
665
+ */
666
+ get writes() {
667
+ return new Set(this._writes);
668
+ }
669
+ }