@git-stunts/git-warp 12.2.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 (45) hide show
  1. package/README.md +8 -6
  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 +4 -1
  6. package/index.d.ts +17 -1
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +1 -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 +30 -23
  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/WriterError.js +5 -0
  16. package/src/domain/errors/index.js +1 -0
  17. package/src/domain/services/AuditVerifierService.js +32 -2
  18. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  19. package/src/domain/services/CheckpointService.js +10 -1
  20. package/src/domain/services/GCPolicy.js +25 -4
  21. package/src/domain/services/GraphTraversal.js +3 -1
  22. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  23. package/src/domain/services/JoinReducer.js +141 -31
  24. package/src/domain/services/MaterializedViewService.js +13 -2
  25. package/src/domain/services/PatchBuilderV2.js +181 -142
  26. package/src/domain/services/QueryBuilder.js +4 -0
  27. package/src/domain/services/SyncController.js +12 -31
  28. package/src/domain/services/SyncProtocol.js +75 -32
  29. package/src/domain/trust/TrustRecordService.js +50 -36
  30. package/src/domain/utils/CachedValue.js +34 -5
  31. package/src/domain/utils/EventId.js +4 -1
  32. package/src/domain/utils/LRUCache.js +3 -1
  33. package/src/domain/utils/RefLayout.js +4 -0
  34. package/src/domain/utils/canonicalStringify.js +48 -18
  35. package/src/domain/utils/matchGlob.js +7 -0
  36. package/src/domain/warp/PatchSession.js +30 -24
  37. package/src/domain/warp/Writer.js +5 -0
  38. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  39. package/src/domain/warp/checkpoint.methods.js +36 -7
  40. package/src/domain/warp/materialize.methods.js +44 -5
  41. package/src/domain/warp/materializeAdvanced.methods.js +50 -10
  42. package/src/domain/warp/patch.methods.js +19 -11
  43. package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
  44. package/src/infrastructure/codecs/CborCodec.js +2 -0
  45. 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
  }
@@ -141,17 +144,23 @@ export class PatchBuilderV2 {
141
144
  this._contentBlobs = [];
142
145
 
143
146
  /**
144
- * Nodes/edges read by this patch (for provenance tracking).
147
+ * Observed operands entities whose current state was consulted to build
148
+ * this patch.
145
149
  *
146
- * Design note: "reads" track observed-dot dependencies — entities whose
147
- * state was consulted to build this patch. Remove operations read the
148
- * entity to observe its dots (OR-Set semantics). "Writes" track new data
149
- * creation (adds). This distinction enables finer-grained provenance
150
- * queries like "which patches wrote to X?" vs "which patches depended on X?"
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
157
+ *
158
+ * The public getter `.reads` and the serialized patch field `reads` retain
159
+ * the historical name for backward compatibility.
151
160
  *
152
161
  * @type {Set<string>}
153
162
  */
154
- this._reads = new Set();
163
+ this._observedOperands = new Set();
155
164
 
156
165
  /**
157
166
  * Nodes/edges written by this patch (for provenance tracking).
@@ -163,6 +172,15 @@ export class PatchBuilderV2 {
163
172
  * @type {Set<string>}
164
173
  */
165
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;
166
184
  }
167
185
 
168
186
  /**
@@ -182,6 +200,16 @@ export class PatchBuilderV2 {
182
200
  return this._snapshotState;
183
201
  }
184
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
+ }
211
+ }
212
+
185
213
  /**
186
214
  * Adds a node to the graph.
187
215
  *
@@ -204,6 +232,7 @@ export class PatchBuilderV2 {
204
232
  * .addEdge('user:alice', 'user:bob', 'follows');
205
233
  */
206
234
  addNode(nodeId) {
235
+ this._assertNotCommitted();
207
236
  const dot = vvIncrement(this._vv, this._writerId);
208
237
  this._ops.push(createNodeAddV2(nodeId, dot));
209
238
  // Provenance: NodeAdd writes the node
@@ -238,6 +267,7 @@ export class PatchBuilderV2 {
238
267
  * builder.removeNode('user:alice'); // Also removes all connected edges
239
268
  */
240
269
  removeNode(nodeId) {
270
+ this._assertNotCommitted();
241
271
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
242
272
  const state = this._getSnapshotState();
243
273
 
@@ -250,7 +280,7 @@ export class PatchBuilderV2 {
250
280
  const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)];
251
281
  this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots));
252
282
  // Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots)
253
- this._reads.add(edgeKey);
283
+ this._observedOperands.add(edgeKey);
254
284
  }
255
285
  }
256
286
 
@@ -287,7 +317,7 @@ export class PatchBuilderV2 {
287
317
  const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [];
288
318
  this._ops.push(createNodeRemoveV2(nodeId, observedDots));
289
319
  // Provenance: NodeRemove reads the node (to observe its dots)
290
- this._reads.add(nodeId);
320
+ this._observedOperands.add(nodeId);
291
321
  return this;
292
322
  }
293
323
 
@@ -318,13 +348,14 @@ export class PatchBuilderV2 {
318
348
  * .addEdge('user:alice', 'user:bob', 'collaborates_with');
319
349
  */
320
350
  addEdge(from, to, label) {
351
+ this._assertNotCommitted();
321
352
  const dot = vvIncrement(this._vv, this._writerId);
322
353
  this._ops.push(createEdgeAddV2(from, to, label, dot));
323
354
  const edgeKey = encodeEdgeKey(from, to, label);
324
355
  this._edgesAdded.add(edgeKey);
325
356
  // Provenance: EdgeAdd reads both endpoint nodes, writes the edge key
326
- this._reads.add(from);
327
- this._reads.add(to);
357
+ this._observedOperands.add(from);
358
+ this._observedOperands.add(to);
328
359
  this._writes.add(edgeKey);
329
360
  return this;
330
361
  }
@@ -355,13 +386,14 @@ export class PatchBuilderV2 {
355
386
  * .removeNode('user:alice');
356
387
  */
357
388
  removeEdge(from, to, label) {
389
+ this._assertNotCommitted();
358
390
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
359
391
  const state = this._getSnapshotState();
360
392
  const edgeKey = encodeEdgeKey(from, to, label);
361
393
  const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
362
394
  this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
363
395
  // Provenance: EdgeRemove reads the edge key (to observe its dots)
364
- this._reads.add(edgeKey);
396
+ this._observedOperands.add(edgeKey);
365
397
  return this;
366
398
  }
367
399
 
@@ -395,10 +427,11 @@ export class PatchBuilderV2 {
395
427
  * .setProperty('user:alice', 'age', 30);
396
428
  */
397
429
  setProperty(nodeId, key, value) {
430
+ this._assertNotCommitted();
398
431
  // Props don't use dots - they use EventId from patch context
399
432
  this._ops.push(createPropSetV2(nodeId, key, value));
400
433
  // Provenance: PropSet reads the node (implicit existence check) and writes the node
401
- this._reads.add(nodeId);
434
+ this._observedOperands.add(nodeId);
402
435
  this._writes.add(nodeId);
403
436
  return this;
404
437
  }
@@ -441,6 +474,7 @@ export class PatchBuilderV2 {
441
474
  * .setEdgeProperty('user:alice', 'user:bob', 'follows', 'public', true);
442
475
  */
443
476
  setEdgeProperty(from, to, label, key, value) {
477
+ this._assertNotCommitted();
444
478
  // Validate edge exists in this patch or in current state
445
479
  const ek = encodeEdgeKey(from, to, label);
446
480
  if (!this._edgesAdded.has(ek)) {
@@ -457,8 +491,9 @@ export class PatchBuilderV2 {
457
491
  // = encodeEdgePropKey(from, to, label, key)
458
492
  const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
459
493
  this._ops.push(createPropSetV2(edgeNode, key, value));
494
+ this._hasEdgeProps = true;
460
495
  // Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
461
- this._reads.add(ek);
496
+ this._observedOperands.add(ek);
462
497
  this._writes.add(ek);
463
498
  return this;
464
499
  }
@@ -479,6 +514,7 @@ export class PatchBuilderV2 {
479
514
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
480
515
  */
481
516
  async attachContent(nodeId, content) {
517
+ this._assertNotCommitted();
482
518
  const oid = await this._persistence.writeBlob(content);
483
519
  this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
484
520
  this._contentBlobs.push(oid);
@@ -496,6 +532,7 @@ export class PatchBuilderV2 {
496
532
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
497
533
  */
498
534
  async attachEdgeContent(from, to, label, content) {
535
+ this._assertNotCommitted();
499
536
  const oid = await this._persistence.writeBlob(content);
500
537
  this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
501
538
  this._contentBlobs.push(oid);
@@ -521,14 +558,14 @@ export class PatchBuilderV2 {
521
558
  * - `ops`: Array of operations (NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet)
522
559
  */
523
560
  build() {
524
- const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
561
+ const schema = this._hasEdgeProps ? 3 : 2;
525
562
  return createPatchV2({
526
563
  schema,
527
564
  writer: this._writerId,
528
565
  lamport: this._lamport,
529
566
  context: vvSerialize(this._vv),
530
567
  ops: this._ops,
531
- reads: [...this._reads].sort(),
568
+ reads: [...this._observedOperands].sort(),
532
569
  writes: [...this._writes].sort(),
533
570
  });
534
571
  }
@@ -537,20 +574,24 @@ export class PatchBuilderV2 {
537
574
  * Commits the patch to the graph.
538
575
  *
539
576
  * This method performs the following steps atomically:
540
- * 1. Validates the patch is non-empty
541
- * 2. Checks for concurrent modifications (compare-and-swap on writer ref)
542
- * 3. Calculates the next lamport timestamp from the parent commit
543
- * 4. Builds the PatchV2 structure with the resolved lamport
544
- * 5. Encodes the patch as CBOR and writes it as a Git blob
545
- * 6. Creates a Git tree containing the patch blob
546
- * 7. Creates a commit with proper trailers linking to the parent
547
- * 8. Updates the writer ref to point to the new commit
548
- * 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)
549
587
  *
550
588
  * The commit is written to the writer's patch chain at:
551
589
  * `refs/warp/<graphName>/writers/<writerId>`
552
590
  *
553
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"`
554
595
  * @throws {Error} If the patch is empty (no operations were added).
555
596
  * Message: `"Cannot commit empty patch: no operations added"`
556
597
  * @throws {WriterError} If a concurrent commit was detected (another process
@@ -578,115 +619,122 @@ export class PatchBuilderV2 {
578
619
  * }
579
620
  */
580
621
  async commit() {
581
- // 1. Reject empty patches
582
- if (this._ops.length === 0) {
583
- throw new Error('Cannot commit empty patch: no operations added');
584
- }
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
+ }
585
629
 
586
- // 2. Race detection: check if writer ref has advanced since builder creation
587
- const writerRef = buildWriterRef(this._graphName, this._writerId);
588
- const currentRefSha = await this._persistence.readRef(writerRef);
589
-
590
- if (currentRefSha !== this._expectedParentSha) {
591
- const err = /** @type {WriterError & { expectedSha: string|null, actualSha: string|null }} */ (new WriterError(
592
- 'WRITER_CAS_CONFLICT',
593
- 'Commit failed: writer ref was updated by another process. Re-materialize and retry.'
594
- ));
595
- err.expectedSha = this._expectedParentSha;
596
- err.actualSha = currentRefSha;
597
- throw err;
598
- }
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
+ }
599
675
 
600
- // 3. Calculate lamport and parent from current ref state.
601
- // Start from this._lamport (set by _nextLamport() in createPatch()), which already
602
- // incorporates the globally-observed max Lamport tick via _maxObservedLamport.
603
- // This ensures a first-time writer whose own chain is empty still commits at a tick
604
- // above any previously-observed writer, winning LWW tiebreakers correctly.
605
- let lamport = this._lamport;
606
- let parentCommit = null;
607
-
608
- if (currentRefSha) {
609
- parentCommit = currentRefSha;
610
- // Read the current patch commit to get its lamport timestamp and take the max,
611
- // so the chain stays monotonic even if the ref advanced since createPatch().
612
- const commitMessage = await this._persistence.showNode(currentRefSha);
613
- const kind = detectMessageKind(commitMessage);
614
-
615
- if (kind === 'patch') {
616
- 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) {
617
725
  try {
618
- patchInfo = decodePatchMessage(commitMessage);
726
+ await this._onCommitSuccess({ patch, sha: newCommitSha });
619
727
  } catch (err) {
620
- throw new Error(
621
- `Failed to parse lamport from writer ref ${writerRef}: ` +
622
- `commit ${currentRefSha} has invalid patch message format`,
623
- { cause: err }
624
- );
728
+ // Commit is already persisted — log but don't fail the caller.
729
+ this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
625
730
  }
626
- lamport = Math.max(this._lamport, patchInfo.lamport + 1);
627
731
  }
628
- // Non-patch ref (checkpoint, etc.): keep lamport from this._lamport
629
- // (already incorporates _maxObservedLamport), matching _nextLamport() behavior.
630
- }
631
-
632
- // 4. Build PatchV2 structure with correct lamport
633
- // Note: Dots were assigned using constructor lamport, but commit lamport may differ.
634
- // For now, we use the calculated lamport for the patch metadata.
635
- // The dots themselves are independent of patch lamport (they use VV counters).
636
- const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
637
- // Use createPatchV2 for consistent patch construction (DRY with build())
638
- const patch = createPatchV2({
639
- schema,
640
- writer: this._writerId,
641
- lamport,
642
- context: vvSerialize(this._vv),
643
- ops: this._ops,
644
- reads: [...this._reads].sort(),
645
- writes: [...this._writes].sort(),
646
- });
647
-
648
- // 5. Encode patch as CBOR and write as a Git blob
649
- const patchCbor = this._codec.encode(patch);
650
- const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
651
-
652
- // 6. Create tree with the patch blob + any content blobs (deduplicated)
653
- // Format for mktree: "mode type oid\tpath"
654
- const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`];
655
- const uniqueBlobs = [...new Set(this._contentBlobs)];
656
- for (const blobOid of uniqueBlobs) {
657
- treeEntries.push(`100644 blob ${blobOid}\t_content_${blobOid}`);
658
- }
659
- const treeOid = await this._persistence.writeTree(treeEntries);
660
-
661
- // 7. Create commit with proper trailers linking to the parent
662
- const commitMessage = encodePatchMessage({
663
- graph: this._graphName,
664
- writer: this._writerId,
665
- lamport,
666
- patchOid: patchBlobOid,
667
- schema,
668
- });
669
- const parents = parentCommit ? [parentCommit] : [];
670
- const newCommitSha = await this._persistence.commitNodeWithTree({
671
- treeOid,
672
- parents,
673
- message: commitMessage,
674
- });
675
732
 
676
- // 8. Update writer ref to point to new commit
677
- await this._persistence.updateRef(writerRef, newCommitSha);
678
-
679
- // 9. Notify success callback (updates graph's version vector + eager re-materialize)
680
- if (this._onCommitSuccess) {
681
- try {
682
- await this._onCommitSuccess({ patch, sha: newCommitSha });
683
- } catch (err) {
684
- // Commit is already persisted — log but don't fail the caller.
685
- this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, err);
686
- }
733
+ this._committed = true;
734
+ return newCommitSha;
735
+ } finally {
736
+ this._committing = false;
687
737
  }
688
-
689
- return newCommitSha;
690
738
  }
691
739
 
692
740
  /**
@@ -723,22 +771,13 @@ export class PatchBuilderV2 {
723
771
  }
724
772
 
725
773
  /**
726
- * Gets the set of node/edge IDs read by this patch.
727
- *
728
- * Returns a frozen copy of the reads tracked for provenance. This includes:
729
- * - Nodes read via `removeNode` (to observe dots)
730
- * - Endpoint nodes read via `addEdge` (implicit existence reference)
731
- * - Edge keys read via `removeEdge` (to observe dots)
732
- * - Nodes read via `setProperty` (implicit existence reference)
733
- * - Edge keys read via `setEdgeProperty` (implicit existence reference)
734
- *
735
- * Note: Returns a defensive copy to prevent external mutation of internal state.
736
- * The returned Set is a copy, so mutations to it do not affect the builder.
737
- *
738
- * @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>}
739
778
  */
740
779
  get reads() {
741
- return new Set(this._reads);
780
+ return new Set(this._observedOperands);
742
781
  }
743
782
 
744
783
  /**
@@ -221,6 +221,10 @@ function deepFreeze(obj) {
221
221
  /**
222
222
  * Creates a deep clone of a value.
223
223
  *
224
+ * Results are deep-frozen to prevent accidental mutation of cached state.
225
+ * structuredClone is preferred; JSON round-trip is the fallback for
226
+ * environments without structuredClone support.
227
+ *
224
228
  * Attempts structuredClone first (Node 17+ / modern browsers), falls back
225
229
  * to JSON round-trip, and returns the original value if both fail (e.g.,
226
230
  * for values containing functions or circular references).
@@ -51,6 +51,7 @@ import SyncTrustGate from './SyncTrustGate.js';
51
51
  * @property {number} _patchesSinceCheckpoint
52
52
  * @property {(op: string, t0: number, opts?: {metrics?: string, error?: Error}) => void} _logTiming
53
53
  * @property {(options?: Record<string, unknown>) => Promise<unknown>} materialize
54
+ * @property {(state: import('../services/JoinReducer.js').WarpStateV5) => Promise<unknown>} _setMaterializedState
54
55
  * @property {() => Promise<string[]>} discoverWriters
55
56
  */
56
57
 
@@ -299,7 +300,7 @@ export default class SyncController {
299
300
  * **Requires a cached state.**
300
301
  *
301
302
  * @param {import('./SyncProtocol.js').SyncResponse} response - The sync response
302
- * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[]}>} Result with updated state and frontier
303
+ * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number, trustVerdict?: string, writersApplied?: string[], skippedWriters: Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>}>} Result with updated state and frontier
303
304
  * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
304
305
  * @throws {SyncError} If trust gate rejects untrusted writers (code: `E_SYNC_UNTRUSTED_WRITER`)
305
306
  */
@@ -333,8 +334,13 @@ export default class SyncController {
333
334
  const currentFrontier = this._host._lastFrontier || createFrontier();
334
335
  const result = /** @type {{state: import('./JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._host._cachedState, currentFrontier));
335
336
 
336
- // Update cached state
337
- this._host._cachedState = result.state;
337
+ // Route through canonical state-install path (B105 / C1 fix).
338
+ // _setMaterializedState sets _cachedState, clears _stateDirty, computes
339
+ // state hash, builds adjacency, and rebuilds indexes via _buildView().
340
+ // Bookkeeping is deferred until after install succeeds so that a failed
341
+ // _setMaterializedState does not leave _lastFrontier/_patchesSinceGC
342
+ // advanced while _cachedState remains stale.
343
+ await this._host._setMaterializedState(result.state);
338
344
 
339
345
  // Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale.
340
346
  this._host._lastFrontier = result.frontier;
@@ -342,32 +348,7 @@ export default class SyncController {
342
348
  // Track patches for GC
343
349
  this._host._patchesSinceGC += result.applied;
344
350
 
345
- // Invalidate derived caches (C1) sync changes underlying state
346
- this._invalidateDerivedCaches();
347
-
348
- // State is now in sync with the frontier -- clear dirty flag
349
- this._host._stateDirty = false;
350
-
351
- return { ...result, writersApplied };
352
- }
353
-
354
- /**
355
- * Invalidates all derived caches on the host graph.
356
- *
357
- * Called after sync apply or join to ensure stale index/provider/view
358
- * data is not returned to callers. The next query or traversal will
359
- * trigger a rebuild.
360
- *
361
- * @private
362
- */
363
- _invalidateDerivedCaches() {
364
- const h = /** @type {import('../WarpGraph.js').default} */ (this._host);
365
- h._materializedGraph = null;
366
- h._logicalIndex = null;
367
- h._propertyReader = null;
368
- h._cachedViewHash = null;
369
- h._cachedIndexTree = null;
370
- h._stateDirty = true;
351
+ return { ...result, writersApplied, skippedWriters: response.skippedWriters || [] };
371
352
  }
372
353
 
373
354
  /**
@@ -396,7 +377,7 @@ export default class SyncController {
396
377
  * @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
397
378
  * @param {boolean} [options.materialize=false] - Auto-materialize after sync
398
379
  * @param {{ secret: string, keyId?: string }} [options.auth] - Client auth credentials
399
- * @returns {Promise<{applied: number, attempts: number, state?: import('./JoinReducer.js').WarpStateV5}>}
380
+ * @returns {Promise<{applied: number, attempts: number, skippedWriters: Array<{writerId: string, reason: string, localSha: string, remoteSha: string|null}>, state?: import('./JoinReducer.js').WarpStateV5}>}
400
381
  */
401
382
  async syncWith(remote, options = {}) {
402
383
  const t0 = this._host._clock.now();
@@ -550,7 +531,7 @@ export default class SyncController {
550
531
 
551
532
  const durationMs = this._host._clock.now() - attemptStart;
552
533
  emit('complete', { durationMs, applied: result.applied });
553
- return { applied: result.applied, attempts: attempt };
534
+ return { applied: result.applied, attempts: attempt, skippedWriters: result.skippedWriters || [] };
554
535
  };
555
536
 
556
537
  try {