@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.
- package/README.md +8 -6
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/warp-graph.js +4 -1
- package/index.d.ts +17 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +1 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +30 -23
- package/src/domain/crdt/VersionVector.js +12 -0
- package/src/domain/errors/PatchError.js +27 -0
- package/src/domain/errors/StorageError.js +8 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditVerifierService.js +32 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/CheckpointService.js +10 -1
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +3 -1
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +141 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +181 -142
- package/src/domain/services/QueryBuilder.js +4 -0
- package/src/domain/services/SyncController.js +12 -31
- package/src/domain/services/SyncProtocol.js +75 -32
- package/src/domain/trust/TrustRecordService.js +50 -36
- package/src/domain/utils/CachedValue.js +34 -5
- package/src/domain/utils/EventId.js +4 -1
- package/src/domain/utils/LRUCache.js +3 -1
- package/src/domain/utils/RefLayout.js +4 -0
- package/src/domain/utils/canonicalStringify.js +48 -18
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +5 -0
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +36 -7
- package/src/domain/warp/materialize.methods.js +44 -5
- package/src/domain/warp/materializeAdvanced.methods.js +50 -10
- package/src/domain/warp/patch.methods.js +19 -11
- package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- 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
|
-
|
|
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
|
-
*
|
|
147
|
+
* Observed operands — entities whose current state was consulted to build
|
|
148
|
+
* this patch.
|
|
145
149
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
327
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
541
|
-
* 2.
|
|
542
|
-
* 3.
|
|
543
|
-
* 4.
|
|
544
|
-
* 5.
|
|
545
|
-
* 6.
|
|
546
|
-
* 7. Creates a
|
|
547
|
-
* 8.
|
|
548
|
-
* 9.
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
726
|
+
await this._onCommitSuccess({ patch, sha: newCommitSha });
|
|
619
727
|
} catch (err) {
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
|
727
|
-
*
|
|
728
|
-
*
|
|
729
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
337
|
-
|
|
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
|
-
|
|
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 {
|