@git-stunts/git-warp 12.1.0 → 12.2.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 (54) hide show
  1. package/README.md +8 -4
  2. package/bin/cli/commands/trust.js +37 -1
  3. package/bin/cli/infrastructure.js +14 -1
  4. package/bin/cli/schemas.js +4 -4
  5. package/bin/warp-graph.js +9 -2
  6. package/index.d.ts +18 -2
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +4 -1
  9. package/src/domain/crdt/Dot.js +5 -0
  10. package/src/domain/crdt/LWW.js +3 -1
  11. package/src/domain/crdt/ORSet.js +63 -27
  12. package/src/domain/crdt/VersionVector.js +12 -0
  13. package/src/domain/errors/PatchError.js +27 -0
  14. package/src/domain/errors/StorageError.js +8 -0
  15. package/src/domain/errors/SyncError.js +1 -0
  16. package/src/domain/errors/TrustError.js +2 -0
  17. package/src/domain/errors/WriterError.js +5 -0
  18. package/src/domain/errors/index.js +1 -0
  19. package/src/domain/services/AuditVerifierService.js +32 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/CheckpointService.js +12 -8
  22. package/src/domain/services/Frontier.js +18 -0
  23. package/src/domain/services/GCPolicy.js +25 -4
  24. package/src/domain/services/GraphTraversal.js +11 -50
  25. package/src/domain/services/HttpSyncServer.js +18 -29
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +164 -31
  28. package/src/domain/services/MaterializedViewService.js +13 -2
  29. package/src/domain/services/PatchBuilderV2.js +210 -145
  30. package/src/domain/services/QueryBuilder.js +67 -30
  31. package/src/domain/services/SyncController.js +62 -18
  32. package/src/domain/services/SyncPayloadSchema.js +236 -0
  33. package/src/domain/services/SyncProtocol.js +102 -40
  34. package/src/domain/services/SyncTrustGate.js +146 -0
  35. package/src/domain/services/TranslationCost.js +2 -2
  36. package/src/domain/trust/TrustRecordService.js +161 -34
  37. package/src/domain/utils/CachedValue.js +34 -5
  38. package/src/domain/utils/EventId.js +4 -1
  39. package/src/domain/utils/LRUCache.js +3 -1
  40. package/src/domain/utils/RefLayout.js +4 -0
  41. package/src/domain/utils/canonicalStringify.js +48 -18
  42. package/src/domain/utils/matchGlob.js +7 -0
  43. package/src/domain/warp/PatchSession.js +30 -24
  44. package/src/domain/warp/Writer.js +12 -5
  45. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  46. package/src/domain/warp/checkpoint.methods.js +102 -16
  47. package/src/domain/warp/materialize.methods.js +47 -5
  48. package/src/domain/warp/materializeAdvanced.methods.js +52 -10
  49. package/src/domain/warp/patch.methods.js +24 -8
  50. package/src/domain/warp/query.methods.js +4 -4
  51. package/src/domain/warp/subscribe.methods.js +11 -19
  52. package/src/infrastructure/adapters/GitGraphAdapter.js +57 -54
  53. package/src/infrastructure/codecs/CborCodec.js +2 -0
  54. package/src/domain/utils/fnv1a.js +0 -20
@@ -46,9 +46,12 @@ function findAttachedData(state, nodeId) {
46
46
  const edges = [];
47
47
  const props = [];
48
48
 
49
+ // Edge keys are encoded as "from\0to\0label". Check prefix for source
50
+ // and interior substring for target — avoids split() on every key.
51
+ const srcPrefix = `${nodeId}\0`;
52
+ const tgtInfix = `\0${nodeId}\0`;
49
53
  for (const key of orsetElements(state.edgeAlive)) {
50
- const parts = key.split('\0');
51
- if (parts[0] === nodeId || parts[1] === nodeId) {
54
+ if (key.startsWith(srcPrefix) || key.includes(tgtInfix)) {
52
55
  edges.push(key);
53
56
  }
54
57
  }
@@ -103,6 +106,15 @@ export class PatchBuilderV2 {
103
106
  /** @type {Function} */
104
107
  this._getCurrentState = getCurrentState; // Function to get current materialized state
105
108
 
109
+ /**
110
+ * Snapshot of state captured at construction time (C4).
111
+ * Lazily populated on first call to _getSnapshotState().
112
+ * Prevents TOCTOU races where concurrent writes change state
113
+ * between remove operations in the same patch.
114
+ * @type {import('./JoinReducer.js').WarpStateV5|null}
115
+ */
116
+ this._snapshotState = /** @type {import('./JoinReducer.js').WarpStateV5|null} */ (/** @type {unknown} */ (undefined)); // undefined = not yet captured
117
+
106
118
  /** @type {string|null} */
107
119
  this._expectedParentSha = expectedParentSha;
108
120
 
@@ -132,17 +144,23 @@ export class PatchBuilderV2 {
132
144
  this._contentBlobs = [];
133
145
 
134
146
  /**
135
- * Nodes/edges read by this patch (for provenance tracking).
147
+ * Observed operands entities whose current state was consulted to build
148
+ * this patch.
149
+ *
150
+ * Semantic model per operation type:
151
+ * - removeNode(id): observes node `id` (reads its OR-Set dots for tombstoning)
152
+ * - removeEdge(from, to, label): observes the edge key
153
+ * - addEdge(from, to, label): observes both endpoint nodes `from` and `to`
154
+ * - setProperty(nodeId, key, value): observes node `nodeId`
155
+ * - setEdgeProperty(from, to, label, key, value): observes the edge key
156
+ * - cascade-generated EdgeRemove: observes the edge key
136
157
  *
137
- * Design note: "reads" track observed-dot dependencies entities whose
138
- * state was consulted to build this patch. Remove operations read the
139
- * entity to observe its dots (OR-Set semantics). "Writes" track new data
140
- * creation (adds). This distinction enables finer-grained provenance
141
- * queries like "which patches wrote to X?" vs "which patches depended on X?"
158
+ * The public getter `.reads` and the serialized patch field `reads` retain
159
+ * the historical name for backward compatibility.
142
160
  *
143
161
  * @type {Set<string>}
144
162
  */
145
- this._reads = new Set();
163
+ this._observedOperands = new Set();
146
164
 
147
165
  /**
148
166
  * Nodes/edges written by this patch (for provenance tracking).
@@ -154,6 +172,42 @@ export class PatchBuilderV2 {
154
172
  * @type {Set<string>}
155
173
  */
156
174
  this._writes = new Set();
175
+
176
+ /** @type {boolean} Whether any edge-property ops have been added (schema 3 flag cache). */
177
+ this._hasEdgeProps = false;
178
+
179
+ /** @type {boolean} */
180
+ this._committed = false;
181
+
182
+ /** @type {boolean} */
183
+ this._committing = false;
184
+ }
185
+
186
+ /**
187
+ * Returns a snapshot of the current state, captured lazily on first call (C4).
188
+ *
189
+ * All remove operations within this patch observe dots from the same
190
+ * state snapshot, preventing TOCTOU races where concurrent writers
191
+ * change state between operations.
192
+ *
193
+ * @returns {import('./JoinReducer.js').WarpStateV5|null}
194
+ * @private
195
+ */
196
+ _getSnapshotState() {
197
+ if (this._snapshotState === undefined) {
198
+ this._snapshotState = this._getCurrentState() || null;
199
+ }
200
+ return this._snapshotState;
201
+ }
202
+
203
+ /**
204
+ * Throws if this builder is no longer open for mutation.
205
+ * @private
206
+ */
207
+ _assertNotCommitted() {
208
+ if (this._committed || this._committing) {
209
+ throw new Error('PatchBuilder already committed — create a new builder');
210
+ }
157
211
  }
158
212
 
159
213
  /**
@@ -178,6 +232,7 @@ export class PatchBuilderV2 {
178
232
  * .addEdge('user:alice', 'user:bob', 'follows');
179
233
  */
180
234
  addNode(nodeId) {
235
+ this._assertNotCommitted();
181
236
  const dot = vvIncrement(this._vv, this._writerId);
182
237
  this._ops.push(createNodeAddV2(nodeId, dot));
183
238
  // Provenance: NodeAdd writes the node
@@ -212,8 +267,9 @@ export class PatchBuilderV2 {
212
267
  * builder.removeNode('user:alice'); // Also removes all connected edges
213
268
  */
214
269
  removeNode(nodeId) {
270
+ this._assertNotCommitted();
215
271
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
216
- const state = this._getCurrentState();
272
+ const state = this._getSnapshotState();
217
273
 
218
274
  // Cascade mode: auto-generate EdgeRemove ops for all connected edges before NodeRemove.
219
275
  // Generated ops appear in the patch for auditability.
@@ -224,7 +280,7 @@ export class PatchBuilderV2 {
224
280
  const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)];
225
281
  this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots));
226
282
  // Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots)
227
- this._reads.add(edgeKey);
283
+ this._observedOperands.add(edgeKey);
228
284
  }
229
285
  }
230
286
 
@@ -261,7 +317,7 @@ export class PatchBuilderV2 {
261
317
  const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [];
262
318
  this._ops.push(createNodeRemoveV2(nodeId, observedDots));
263
319
  // Provenance: NodeRemove reads the node (to observe its dots)
264
- this._reads.add(nodeId);
320
+ this._observedOperands.add(nodeId);
265
321
  return this;
266
322
  }
267
323
 
@@ -292,13 +348,14 @@ export class PatchBuilderV2 {
292
348
  * .addEdge('user:alice', 'user:bob', 'collaborates_with');
293
349
  */
294
350
  addEdge(from, to, label) {
351
+ this._assertNotCommitted();
295
352
  const dot = vvIncrement(this._vv, this._writerId);
296
353
  this._ops.push(createEdgeAddV2(from, to, label, dot));
297
354
  const edgeKey = encodeEdgeKey(from, to, label);
298
355
  this._edgesAdded.add(edgeKey);
299
356
  // Provenance: EdgeAdd reads both endpoint nodes, writes the edge key
300
- this._reads.add(from);
301
- this._reads.add(to);
357
+ this._observedOperands.add(from);
358
+ this._observedOperands.add(to);
302
359
  this._writes.add(edgeKey);
303
360
  return this;
304
361
  }
@@ -329,13 +386,14 @@ export class PatchBuilderV2 {
329
386
  * .removeNode('user:alice');
330
387
  */
331
388
  removeEdge(from, to, label) {
389
+ this._assertNotCommitted();
332
390
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
333
- const state = this._getCurrentState();
391
+ const state = this._getSnapshotState();
334
392
  const edgeKey = encodeEdgeKey(from, to, label);
335
393
  const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
336
394
  this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
337
395
  // Provenance: EdgeRemove reads the edge key (to observe its dots)
338
- this._reads.add(edgeKey);
396
+ this._observedOperands.add(edgeKey);
339
397
  return this;
340
398
  }
341
399
 
@@ -369,10 +427,11 @@ export class PatchBuilderV2 {
369
427
  * .setProperty('user:alice', 'age', 30);
370
428
  */
371
429
  setProperty(nodeId, key, value) {
430
+ this._assertNotCommitted();
372
431
  // Props don't use dots - they use EventId from patch context
373
432
  this._ops.push(createPropSetV2(nodeId, key, value));
374
433
  // Provenance: PropSet reads the node (implicit existence check) and writes the node
375
- this._reads.add(nodeId);
434
+ this._observedOperands.add(nodeId);
376
435
  this._writes.add(nodeId);
377
436
  return this;
378
437
  }
@@ -415,10 +474,11 @@ export class PatchBuilderV2 {
415
474
  * .setEdgeProperty('user:alice', 'user:bob', 'follows', 'public', true);
416
475
  */
417
476
  setEdgeProperty(from, to, label, key, value) {
477
+ this._assertNotCommitted();
418
478
  // Validate edge exists in this patch or in current state
419
479
  const ek = encodeEdgeKey(from, to, label);
420
480
  if (!this._edgesAdded.has(ek)) {
421
- const state = this._getCurrentState();
481
+ const state = this._getSnapshotState();
422
482
  if (!state || !orsetContains(state.edgeAlive, ek)) {
423
483
  throw new Error(`Cannot set property on unknown edge (${from} → ${to} [${label}]): add the edge first`);
424
484
  }
@@ -431,8 +491,9 @@ export class PatchBuilderV2 {
431
491
  // = encodeEdgePropKey(from, to, label, key)
432
492
  const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
433
493
  this._ops.push(createPropSetV2(edgeNode, key, value));
494
+ this._hasEdgeProps = true;
434
495
  // Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
435
- this._reads.add(ek);
496
+ this._observedOperands.add(ek);
436
497
  this._writes.add(ek);
437
498
  return this;
438
499
  }
@@ -453,6 +514,7 @@ export class PatchBuilderV2 {
453
514
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
454
515
  */
455
516
  async attachContent(nodeId, content) {
517
+ this._assertNotCommitted();
456
518
  const oid = await this._persistence.writeBlob(content);
457
519
  this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
458
520
  this._contentBlobs.push(oid);
@@ -470,6 +532,7 @@ export class PatchBuilderV2 {
470
532
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
471
533
  */
472
534
  async attachEdgeContent(from, to, label, content) {
535
+ this._assertNotCommitted();
473
536
  const oid = await this._persistence.writeBlob(content);
474
537
  this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
475
538
  this._contentBlobs.push(oid);
@@ -495,14 +558,14 @@ export class PatchBuilderV2 {
495
558
  * - `ops`: Array of operations (NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet)
496
559
  */
497
560
  build() {
498
- const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
561
+ const schema = this._hasEdgeProps ? 3 : 2;
499
562
  return createPatchV2({
500
563
  schema,
501
564
  writer: this._writerId,
502
565
  lamport: this._lamport,
503
566
  context: vvSerialize(this._vv),
504
567
  ops: this._ops,
505
- reads: [...this._reads].sort(),
568
+ reads: [...this._observedOperands].sort(),
506
569
  writes: [...this._writes].sort(),
507
570
  });
508
571
  }
@@ -511,20 +574,24 @@ export class PatchBuilderV2 {
511
574
  * Commits the patch to the graph.
512
575
  *
513
576
  * This method performs the following steps atomically:
514
- * 1. Validates the patch is non-empty
515
- * 2. Checks for concurrent modifications (compare-and-swap on writer ref)
516
- * 3. Calculates the next lamport timestamp from the parent commit
517
- * 4. Builds the PatchV2 structure with the resolved lamport
518
- * 5. Encodes the patch as CBOR and writes it as a Git blob
519
- * 6. Creates a Git tree containing the patch blob
520
- * 7. Creates a commit with proper trailers linking to the parent
521
- * 8. Updates the writer ref to point to the new commit
522
- * 9. Invokes the success callback if provided (for eager re-materialization)
577
+ * 1. Verifies this builder is still open (not committed and not in-flight)
578
+ * 2. Validates the patch is non-empty
579
+ * 3. Checks for concurrent modifications (compare-and-swap on writer ref)
580
+ * 4. Calculates the next lamport timestamp from the parent commit
581
+ * 5. Builds the PatchV2 structure with the resolved lamport
582
+ * 6. Encodes the patch as CBOR and writes it as a Git blob
583
+ * 7. Creates a Git tree containing the patch blob
584
+ * 8. Creates a commit with proper trailers linking to the parent
585
+ * 9. Updates the writer ref to point to the new commit
586
+ * 10. Invokes the success callback if provided (for eager re-materialization)
523
587
  *
524
588
  * The commit is written to the writer's patch chain at:
525
589
  * `refs/warp/<graphName>/writers/<writerId>`
526
590
  *
527
591
  * @returns {Promise<string>} The commit SHA of the new patch commit
592
+ * @throws {Error} If this builder has already been committed, or a commit
593
+ * is currently in-flight on this builder.
594
+ * Message: `"PatchBuilder already committed — create a new builder"`
528
595
  * @throws {Error} If the patch is empty (no operations were added).
529
596
  * Message: `"Cannot commit empty patch: no operations added"`
530
597
  * @throws {WriterError} If a concurrent commit was detected (another process
@@ -552,115 +619,122 @@ export class PatchBuilderV2 {
552
619
  * }
553
620
  */
554
621
  async commit() {
555
- // 1. Reject empty patches
556
- if (this._ops.length === 0) {
557
- throw new Error('Cannot commit empty patch: no operations added');
558
- }
622
+ this._assertNotCommitted();
623
+ this._committing = true;
624
+ try {
625
+ // 2. Reject empty patches
626
+ if (this._ops.length === 0) {
627
+ throw new Error('Cannot commit empty patch: no operations added');
628
+ }
559
629
 
560
- // 2. Race detection: check if writer ref has advanced since builder creation
561
- const writerRef = buildWriterRef(this._graphName, this._writerId);
562
- const currentRefSha = await this._persistence.readRef(writerRef);
563
-
564
- if (currentRefSha !== this._expectedParentSha) {
565
- const err = /** @type {WriterError & { expectedSha: string|null, actualSha: string|null }} */ (new WriterError(
566
- 'WRITER_CAS_CONFLICT',
567
- 'Commit failed: writer ref was updated by another process. Re-materialize and retry.'
568
- ));
569
- err.expectedSha = this._expectedParentSha;
570
- err.actualSha = currentRefSha;
571
- throw err;
572
- }
630
+ // 3. Race detection: check if writer ref has advanced since builder creation
631
+ const writerRef = buildWriterRef(this._graphName, this._writerId);
632
+ const currentRefSha = await this._persistence.readRef(writerRef);
633
+
634
+ if (currentRefSha !== this._expectedParentSha) {
635
+ const err = /** @type {WriterError & { expectedSha: string|null, actualSha: string|null }} */ (new WriterError(
636
+ 'WRITER_CAS_CONFLICT',
637
+ 'Commit failed: writer ref was updated by another process. Re-materialize and retry.'
638
+ ));
639
+ err.expectedSha = this._expectedParentSha;
640
+ err.actualSha = currentRefSha;
641
+ throw err;
642
+ }
643
+
644
+ // 4. Calculate lamport and parent from current ref state.
645
+ // Start from this._lamport (set by _nextLamport() in createPatch()), which already
646
+ // incorporates the globally-observed max Lamport tick via _maxObservedLamport.
647
+ // This ensures a first-time writer whose own chain is empty still commits at a tick
648
+ // above any previously-observed writer, winning LWW tiebreakers correctly.
649
+ let lamport = this._lamport;
650
+ let parentCommit = null;
651
+
652
+ if (currentRefSha) {
653
+ parentCommit = currentRefSha;
654
+ // Read the current patch commit to get its lamport timestamp and take the max,
655
+ // so the chain stays monotonic even if the ref advanced since createPatch().
656
+ const commitMessage = await this._persistence.showNode(currentRefSha);
657
+ const kind = detectMessageKind(commitMessage);
658
+
659
+ if (kind === 'patch') {
660
+ let patchInfo;
661
+ try {
662
+ patchInfo = decodePatchMessage(commitMessage);
663
+ } catch (err) {
664
+ throw new Error(
665
+ `Failed to parse lamport from writer ref ${writerRef}: ` +
666
+ `commit ${currentRefSha} has invalid patch message format`,
667
+ { cause: err }
668
+ );
669
+ }
670
+ lamport = Math.max(this._lamport, patchInfo.lamport + 1);
671
+ }
672
+ // Non-patch ref (checkpoint, etc.): keep lamport from this._lamport
673
+ // (already incorporates _maxObservedLamport), matching _nextLamport() behavior.
674
+ }
573
675
 
574
- // 3. Calculate lamport and parent from current ref state.
575
- // Start from this._lamport (set by _nextLamport() in createPatch()), which already
576
- // incorporates the globally-observed max Lamport tick via _maxObservedLamport.
577
- // This ensures a first-time writer whose own chain is empty still commits at a tick
578
- // above any previously-observed writer, winning LWW tiebreakers correctly.
579
- let lamport = this._lamport;
580
- let parentCommit = null;
581
-
582
- if (currentRefSha) {
583
- parentCommit = currentRefSha;
584
- // Read the current patch commit to get its lamport timestamp and take the max,
585
- // so the chain stays monotonic even if the ref advanced since createPatch().
586
- const commitMessage = await this._persistence.showNode(currentRefSha);
587
- const kind = detectMessageKind(commitMessage);
588
-
589
- if (kind === 'patch') {
590
- let patchInfo;
676
+ // 5. Build PatchV2 structure with correct lamport
677
+ // Note: Dots were assigned using constructor lamport, but commit lamport may differ.
678
+ // For now, we use the calculated lamport for the patch metadata.
679
+ // The dots themselves are independent of patch lamport (they use VV counters).
680
+ const schema = this._hasEdgeProps ? 3 : 2;
681
+ // Use createPatchV2 for consistent patch construction (DRY with build())
682
+ const patch = createPatchV2({
683
+ schema,
684
+ writer: this._writerId,
685
+ lamport,
686
+ context: vvSerialize(this._vv),
687
+ ops: this._ops,
688
+ reads: [...this._observedOperands].sort(),
689
+ writes: [...this._writes].sort(),
690
+ });
691
+
692
+ // 6. Encode patch as CBOR and write as a Git blob
693
+ const patchCbor = this._codec.encode(patch);
694
+ const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
695
+
696
+ // 7. Create tree with the patch blob + any content blobs (deduplicated)
697
+ // Format for mktree: "mode type oid\tpath"
698
+ const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`];
699
+ const uniqueBlobs = [...new Set(this._contentBlobs)];
700
+ for (const blobOid of uniqueBlobs) {
701
+ treeEntries.push(`100644 blob ${blobOid}\t_content_${blobOid}`);
702
+ }
703
+ const treeOid = await this._persistence.writeTree(treeEntries);
704
+
705
+ // 8. Create commit with proper trailers linking to the parent
706
+ const commitMessage = encodePatchMessage({
707
+ graph: this._graphName,
708
+ writer: this._writerId,
709
+ lamport,
710
+ patchOid: patchBlobOid,
711
+ schema,
712
+ });
713
+ const parents = parentCommit ? [parentCommit] : [];
714
+ const newCommitSha = await this._persistence.commitNodeWithTree({
715
+ treeOid,
716
+ parents,
717
+ message: commitMessage,
718
+ });
719
+
720
+ // 9. Update writer ref to point to new commit
721
+ await this._persistence.updateRef(writerRef, newCommitSha);
722
+
723
+ // 10. Notify success callback (updates graph's version vector + eager re-materialize)
724
+ if (this._onCommitSuccess) {
591
725
  try {
592
- patchInfo = decodePatchMessage(commitMessage);
726
+ await this._onCommitSuccess({ patch, sha: newCommitSha });
593
727
  } catch (err) {
594
- throw new Error(
595
- `Failed to parse lamport from writer ref ${writerRef}: ` +
596
- `commit ${currentRefSha} has invalid patch message format`,
597
- { cause: err }
598
- );
728
+ // Commit is already persisted — log but don't fail the caller.
729
+ this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
599
730
  }
600
- lamport = Math.max(this._lamport, patchInfo.lamport + 1);
601
731
  }
602
- // Non-patch ref (checkpoint, etc.): keep lamport from this._lamport
603
- // (already incorporates _maxObservedLamport), matching _nextLamport() behavior.
604
- }
605
-
606
- // 4. Build PatchV2 structure with correct lamport
607
- // Note: Dots were assigned using constructor lamport, but commit lamport may differ.
608
- // For now, we use the calculated lamport for the patch metadata.
609
- // The dots themselves are independent of patch lamport (they use VV counters).
610
- const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
611
- // Use createPatchV2 for consistent patch construction (DRY with build())
612
- const patch = createPatchV2({
613
- schema,
614
- writer: this._writerId,
615
- lamport,
616
- context: vvSerialize(this._vv),
617
- ops: this._ops,
618
- reads: [...this._reads].sort(),
619
- writes: [...this._writes].sort(),
620
- });
621
-
622
- // 5. Encode patch as CBOR and write as a Git blob
623
- const patchCbor = this._codec.encode(patch);
624
- const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
625
-
626
- // 6. Create tree with the patch blob + any content blobs (deduplicated)
627
- // Format for mktree: "mode type oid\tpath"
628
- const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`];
629
- const uniqueBlobs = [...new Set(this._contentBlobs)];
630
- for (const blobOid of uniqueBlobs) {
631
- treeEntries.push(`100644 blob ${blobOid}\t_content_${blobOid}`);
632
- }
633
- const treeOid = await this._persistence.writeTree(treeEntries);
634
732
 
635
- // 7. Create commit with proper trailers linking to the parent
636
- const commitMessage = encodePatchMessage({
637
- graph: this._graphName,
638
- writer: this._writerId,
639
- lamport,
640
- patchOid: patchBlobOid,
641
- schema,
642
- });
643
- const parents = parentCommit ? [parentCommit] : [];
644
- const newCommitSha = await this._persistence.commitNodeWithTree({
645
- treeOid,
646
- parents,
647
- message: commitMessage,
648
- });
649
-
650
- // 8. Update writer ref to point to new commit
651
- await this._persistence.updateRef(writerRef, newCommitSha);
652
-
653
- // 9. Notify success callback (updates graph's version vector + eager re-materialize)
654
- if (this._onCommitSuccess) {
655
- try {
656
- await this._onCommitSuccess({ patch, sha: newCommitSha });
657
- } catch (err) {
658
- // Commit is already persisted — log but don't fail the caller.
659
- this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
660
- }
733
+ this._committed = true;
734
+ return newCommitSha;
735
+ } finally {
736
+ this._committing = false;
661
737
  }
662
-
663
- return newCommitSha;
664
738
  }
665
739
 
666
740
  /**
@@ -697,22 +771,13 @@ export class PatchBuilderV2 {
697
771
  }
698
772
 
699
773
  /**
700
- * Gets the set of node/edge IDs read by this patch.
701
- *
702
- * Returns a frozen copy of the reads tracked for provenance. This includes:
703
- * - Nodes read via `removeNode` (to observe dots)
704
- * - Endpoint nodes read via `addEdge` (implicit existence reference)
705
- * - Edge keys read via `removeEdge` (to observe dots)
706
- * - Nodes read via `setProperty` (implicit existence reference)
707
- * - Edge keys read via `setEdgeProperty` (implicit existence reference)
708
- *
709
- * Note: Returns a defensive copy to prevent external mutation of internal state.
710
- * The returned Set is a copy, so mutations to it do not affect the builder.
711
- *
712
- * @returns {ReadonlySet<string>} Copy of node IDs and encoded edge keys that were read
774
+ * Gets the set of observed operands (entities whose state was consulted).
775
+ * Retains the `reads` name for API/serialization compatibility.
776
+ * Internal field is `_observedOperands`.
777
+ * @returns {ReadonlySet<string>}
713
778
  */
714
779
  get reads() {
715
- return new Set(this._reads);
780
+ return new Set(this._observedOperands);
716
781
  }
717
782
 
718
783
  /**