@git-stunts/git-warp 12.2.0 → 12.3.0

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 (59) hide show
  1. package/README.md +9 -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/presenters/text.js +10 -3
  6. package/bin/warp-graph.js +4 -1
  7. package/index.d.ts +17 -1
  8. package/package.json +1 -1
  9. package/src/domain/WarpGraph.js +1 -1
  10. package/src/domain/crdt/Dot.js +5 -0
  11. package/src/domain/crdt/LWW.js +3 -1
  12. package/src/domain/crdt/ORSet.js +33 -23
  13. package/src/domain/crdt/VersionVector.js +12 -0
  14. package/src/domain/errors/PatchError.js +27 -0
  15. package/src/domain/errors/StorageError.js +8 -0
  16. package/src/domain/errors/WriterError.js +5 -0
  17. package/src/domain/errors/index.js +1 -0
  18. package/src/domain/services/AuditReceiptService.js +2 -1
  19. package/src/domain/services/AuditVerifierService.js +33 -2
  20. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  21. package/src/domain/services/BoundaryTransitionRecord.js +1 -0
  22. package/src/domain/services/CheckpointMessageCodec.js +5 -0
  23. package/src/domain/services/CheckpointService.js +29 -2
  24. package/src/domain/services/GCPolicy.js +25 -4
  25. package/src/domain/services/GraphTraversal.js +3 -1
  26. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  27. package/src/domain/services/JoinReducer.js +311 -75
  28. package/src/domain/services/KeyCodec.js +48 -0
  29. package/src/domain/services/MaterializedViewService.js +14 -3
  30. package/src/domain/services/MessageSchemaDetector.js +35 -5
  31. package/src/domain/services/OpNormalizer.js +79 -0
  32. package/src/domain/services/PatchBuilderV2.js +240 -160
  33. package/src/domain/services/QueryBuilder.js +4 -0
  34. package/src/domain/services/SyncAuthService.js +3 -0
  35. package/src/domain/services/SyncController.js +12 -31
  36. package/src/domain/services/SyncProtocol.js +76 -32
  37. package/src/domain/services/WarpMessageCodec.js +2 -0
  38. package/src/domain/trust/TrustCrypto.js +8 -5
  39. package/src/domain/trust/TrustRecordService.js +50 -36
  40. package/src/domain/types/TickReceipt.js +6 -4
  41. package/src/domain/types/WarpTypesV2.js +77 -5
  42. package/src/domain/utils/CachedValue.js +34 -5
  43. package/src/domain/utils/EventId.js +4 -1
  44. package/src/domain/utils/LRUCache.js +3 -1
  45. package/src/domain/utils/RefLayout.js +4 -0
  46. package/src/domain/utils/canonicalStringify.js +48 -18
  47. package/src/domain/utils/defaultClock.js +1 -0
  48. package/src/domain/utils/matchGlob.js +7 -0
  49. package/src/domain/warp/PatchSession.js +30 -24
  50. package/src/domain/warp/Writer.js +12 -1
  51. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  52. package/src/domain/warp/checkpoint.methods.js +36 -7
  53. package/src/domain/warp/fork.methods.js +1 -1
  54. package/src/domain/warp/materialize.methods.js +44 -5
  55. package/src/domain/warp/materializeAdvanced.methods.js +50 -10
  56. package/src/domain/warp/patch.methods.js +21 -11
  57. package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
  58. package/src/infrastructure/codecs/CborCodec.js +2 -0
  59. package/src/domain/utils/fnv1a.js +0 -20
@@ -20,10 +20,12 @@ import {
20
20
  createNodeRemoveV2,
21
21
  createEdgeAddV2,
22
22
  createEdgeRemoveV2,
23
- createPropSetV2,
23
+ createNodePropSetV2,
24
+ createEdgePropSetV2,
24
25
  createPatchV2,
25
26
  } from '../types/WarpTypesV2.js';
26
- import { encodeEdgeKey, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
27
+ import { encodeEdgeKey, FIELD_SEPARATOR, EDGE_PROP_PREFIX, CONTENT_PROPERTY_KEY } from './KeyCodec.js';
28
+ import { lowerCanonicalOp } from './OpNormalizer.js';
27
29
  import { encodePatchMessage, decodePatchMessage, detectMessageKind } from './WarpMessageCodec.js';
28
30
  import { buildWriterRef } from '../utils/RefLayout.js';
29
31
  import WriterError from '../errors/WriterError.js';
@@ -46,9 +48,12 @@ function findAttachedData(state, nodeId) {
46
48
  const edges = [];
47
49
  const props = [];
48
50
 
51
+ // Edge keys are encoded as "from\0to\0label". Check prefix for source
52
+ // and interior substring for target — avoids split() on every key.
53
+ const srcPrefix = `${nodeId}\0`;
54
+ const tgtInfix = `\0${nodeId}\0`;
49
55
  for (const key of orsetElements(state.edgeAlive)) {
50
- const parts = key.split('\0');
51
- if (parts[0] === nodeId || parts[1] === nodeId) {
56
+ if (key.startsWith(srcPrefix) || key.includes(tgtInfix)) {
52
57
  edges.push(key);
53
58
  }
54
59
  }
@@ -63,6 +68,30 @@ function findAttachedData(state, nodeId) {
63
68
  return { edges, props, hasData: edges.length > 0 || props.length > 0 };
64
69
  }
65
70
 
71
+ /**
72
+ * Validates that an identifier does not contain reserved bytes that would
73
+ * make the legacy edge-property encoding ambiguous.
74
+ *
75
+ * Rejects:
76
+ * - Identifiers containing \0 (field separator)
77
+ * - Identifiers starting with \x01 (edge property prefix)
78
+ *
79
+ * @param {string} value - Identifier to validate
80
+ * @param {string} label - Human-readable label for error messages
81
+ * @throws {Error} If the identifier contains reserved bytes
82
+ */
83
+ function _assertNoReservedBytes(value, label) {
84
+ if (typeof value !== 'string') {
85
+ throw new Error(`${label} must be a string, got ${typeof value}`);
86
+ }
87
+ if (value.includes(FIELD_SEPARATOR)) {
88
+ throw new Error(`${label} must not contain null bytes (\\0): ${JSON.stringify(value)}`);
89
+ }
90
+ if (value.length > 0 && value[0] === EDGE_PROP_PREFIX) {
91
+ throw new Error(`${label} must not start with reserved prefix \\x01: ${JSON.stringify(value)}`);
92
+ }
93
+ }
94
+
66
95
  /**
67
96
  * Fluent builder for creating WARP v5 patches with dots and observed-remove semantics.
68
97
  */
@@ -82,7 +111,7 @@ export class PatchBuilderV2 {
82
111
  * @param {Function|null} [options.onCommitSuccess] - Callback invoked after successful commit
83
112
  * @param {'reject'|'cascade'|'warn'} [options.onDeleteWithData='warn'] - Policy when deleting a node with attached data
84
113
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization
85
- * @param {{ warn: Function }} [options.logger] - Logger for non-fatal warnings
114
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for non-fatal warnings
86
115
  */
87
116
  constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) {
88
117
  /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} */
@@ -130,7 +159,7 @@ export class PatchBuilderV2 {
130
159
  /** @type {import('../../ports/CodecPort.js').default} */
131
160
  this._codec = codec || defaultCodec;
132
161
 
133
- /** @type {{ warn: Function }} */
162
+ /** @type {import('../../ports/LoggerPort.js').default} */
134
163
  this._logger = logger || nullLogger;
135
164
 
136
165
  /**
@@ -141,17 +170,23 @@ export class PatchBuilderV2 {
141
170
  this._contentBlobs = [];
142
171
 
143
172
  /**
144
- * Nodes/edges read by this patch (for provenance tracking).
173
+ * Observed operands entities whose current state was consulted to build
174
+ * this patch.
175
+ *
176
+ * Semantic model per operation type:
177
+ * - removeNode(id): observes node `id` (reads its OR-Set dots for tombstoning)
178
+ * - removeEdge(from, to, label): observes the edge key
179
+ * - addEdge(from, to, label): observes both endpoint nodes `from` and `to`
180
+ * - setProperty(nodeId, key, value): observes node `nodeId`
181
+ * - setEdgeProperty(from, to, label, key, value): observes the edge key
182
+ * - cascade-generated EdgeRemove: observes the edge key
145
183
  *
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?"
184
+ * The public getter `.reads` and the serialized patch field `reads` retain
185
+ * the historical name for backward compatibility.
151
186
  *
152
187
  * @type {Set<string>}
153
188
  */
154
- this._reads = new Set();
189
+ this._observedOperands = new Set();
155
190
 
156
191
  /**
157
192
  * Nodes/edges written by this patch (for provenance tracking).
@@ -163,6 +198,15 @@ export class PatchBuilderV2 {
163
198
  * @type {Set<string>}
164
199
  */
165
200
  this._writes = new Set();
201
+
202
+ /** @type {boolean} Whether any edge-property ops have been added (schema 3 flag cache). */
203
+ this._hasEdgeProps = false;
204
+
205
+ /** @type {boolean} */
206
+ this._committed = false;
207
+
208
+ /** @type {boolean} */
209
+ this._committing = false;
166
210
  }
167
211
 
168
212
  /**
@@ -182,6 +226,16 @@ export class PatchBuilderV2 {
182
226
  return this._snapshotState;
183
227
  }
184
228
 
229
+ /**
230
+ * Throws if this builder is no longer open for mutation.
231
+ * @private
232
+ */
233
+ _assertNotCommitted() {
234
+ if (this._committed || this._committing) {
235
+ throw new Error('PatchBuilder already committed — create a new builder');
236
+ }
237
+ }
238
+
185
239
  /**
186
240
  * Adds a node to the graph.
187
241
  *
@@ -204,6 +258,8 @@ export class PatchBuilderV2 {
204
258
  * .addEdge('user:alice', 'user:bob', 'follows');
205
259
  */
206
260
  addNode(nodeId) {
261
+ this._assertNotCommitted();
262
+ _assertNoReservedBytes(nodeId, 'nodeId');
207
263
  const dot = vvIncrement(this._vv, this._writerId);
208
264
  this._ops.push(createNodeAddV2(nodeId, dot));
209
265
  // Provenance: NodeAdd writes the node
@@ -238,6 +294,7 @@ export class PatchBuilderV2 {
238
294
  * builder.removeNode('user:alice'); // Also removes all connected edges
239
295
  */
240
296
  removeNode(nodeId) {
297
+ this._assertNotCommitted();
241
298
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
242
299
  const state = this._getSnapshotState();
243
300
 
@@ -250,7 +307,7 @@ export class PatchBuilderV2 {
250
307
  const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)];
251
308
  this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots));
252
309
  // Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots)
253
- this._reads.add(edgeKey);
310
+ this._observedOperands.add(edgeKey);
254
311
  }
255
312
  }
256
313
 
@@ -275,8 +332,7 @@ export class PatchBuilderV2 {
275
332
  }
276
333
 
277
334
  if (this._onDeleteWithData === 'warn') {
278
- // eslint-disable-next-line no-console
279
- console.warn(
335
+ this._logger.warn(
280
336
  `[warp] Deleting node '${nodeId}' which has attached data (${summary}). ` +
281
337
  `Orphaned data will remain in state.`
282
338
  );
@@ -287,7 +343,7 @@ export class PatchBuilderV2 {
287
343
  const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [];
288
344
  this._ops.push(createNodeRemoveV2(nodeId, observedDots));
289
345
  // Provenance: NodeRemove reads the node (to observe its dots)
290
- this._reads.add(nodeId);
346
+ this._observedOperands.add(nodeId);
291
347
  return this;
292
348
  }
293
349
 
@@ -318,13 +374,17 @@ export class PatchBuilderV2 {
318
374
  * .addEdge('user:alice', 'user:bob', 'collaborates_with');
319
375
  */
320
376
  addEdge(from, to, label) {
377
+ this._assertNotCommitted();
378
+ _assertNoReservedBytes(from, 'from node ID');
379
+ _assertNoReservedBytes(to, 'to node ID');
380
+ _assertNoReservedBytes(label, 'edge label');
321
381
  const dot = vvIncrement(this._vv, this._writerId);
322
382
  this._ops.push(createEdgeAddV2(from, to, label, dot));
323
383
  const edgeKey = encodeEdgeKey(from, to, label);
324
384
  this._edgesAdded.add(edgeKey);
325
385
  // Provenance: EdgeAdd reads both endpoint nodes, writes the edge key
326
- this._reads.add(from);
327
- this._reads.add(to);
386
+ this._observedOperands.add(from);
387
+ this._observedOperands.add(to);
328
388
  this._writes.add(edgeKey);
329
389
  return this;
330
390
  }
@@ -355,13 +415,14 @@ export class PatchBuilderV2 {
355
415
  * .removeNode('user:alice');
356
416
  */
357
417
  removeEdge(from, to, label) {
418
+ this._assertNotCommitted();
358
419
  // Get observed dots from current state (orsetGetDots returns already-encoded dot strings)
359
420
  const state = this._getSnapshotState();
360
421
  const edgeKey = encodeEdgeKey(from, to, label);
361
422
  const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [];
362
423
  this._ops.push(createEdgeRemoveV2(from, to, label, observedDots));
363
424
  // Provenance: EdgeRemove reads the edge key (to observe its dots)
364
- this._reads.add(edgeKey);
425
+ this._observedOperands.add(edgeKey);
365
426
  return this;
366
427
  }
367
428
 
@@ -395,10 +456,13 @@ export class PatchBuilderV2 {
395
456
  * .setProperty('user:alice', 'age', 30);
396
457
  */
397
458
  setProperty(nodeId, key, value) {
398
- // Props don't use dots - they use EventId from patch context
399
- this._ops.push(createPropSetV2(nodeId, key, value));
400
- // Provenance: PropSet reads the node (implicit existence check) and writes the node
401
- this._reads.add(nodeId);
459
+ this._assertNotCommitted();
460
+ _assertNoReservedBytes(nodeId, 'nodeId');
461
+ _assertNoReservedBytes(key, 'property key');
462
+ // Canonical NodePropSet — lowered to raw PropSet at commit time
463
+ this._ops.push(createNodePropSetV2(nodeId, key, value));
464
+ // Provenance: NodePropSet reads the node (implicit existence check) and writes the node
465
+ this._observedOperands.add(nodeId);
402
466
  this._writes.add(nodeId);
403
467
  return this;
404
468
  }
@@ -441,6 +505,11 @@ export class PatchBuilderV2 {
441
505
  * .setEdgeProperty('user:alice', 'user:bob', 'follows', 'public', true);
442
506
  */
443
507
  setEdgeProperty(from, to, label, key, value) {
508
+ this._assertNotCommitted();
509
+ _assertNoReservedBytes(from, 'from node ID');
510
+ _assertNoReservedBytes(to, 'to node ID');
511
+ _assertNoReservedBytes(label, 'edge label');
512
+ _assertNoReservedBytes(key, 'property key');
444
513
  // Validate edge exists in this patch or in current state
445
514
  const ek = encodeEdgeKey(from, to, label);
446
515
  if (!this._edgesAdded.has(ek)) {
@@ -450,15 +519,11 @@ export class PatchBuilderV2 {
450
519
  }
451
520
  }
452
521
 
453
- // Encode the edge identity as the "node" field with the \x01 prefix.
454
- // When JoinReducer processes: encodePropKey(op.node, op.key)
455
- // = `\x01from\0to\0label` + `\0` + key
456
- // = `\x01from\0to\0label\0key`
457
- // = encodeEdgePropKey(from, to, label, key)
458
- const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
459
- this._ops.push(createPropSetV2(edgeNode, key, value));
460
- // Provenance: setEdgeProperty reads the edge (implicit existence check) and writes the edge
461
- this._reads.add(ek);
522
+ // Canonical EdgePropSet lowered to legacy raw PropSet at commit time
523
+ this._ops.push(createEdgePropSetV2(from, to, label, key, value));
524
+ this._hasEdgeProps = true;
525
+ // Provenance: EdgePropSet reads the edge (implicit existence check) and writes the edge
526
+ this._observedOperands.add(ek);
462
527
  this._writes.add(ek);
463
528
  return this;
464
529
  }
@@ -479,6 +544,10 @@ export class PatchBuilderV2 {
479
544
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
480
545
  */
481
546
  async attachContent(nodeId, content) {
547
+ this._assertNotCommitted();
548
+ // Validate identifiers before writing blob to avoid orphaned blobs
549
+ _assertNoReservedBytes(nodeId, 'nodeId');
550
+ _assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
482
551
  const oid = await this._persistence.writeBlob(content);
483
552
  this.setProperty(nodeId, CONTENT_PROPERTY_KEY, oid);
484
553
  this._contentBlobs.push(oid);
@@ -496,6 +565,12 @@ export class PatchBuilderV2 {
496
565
  * @returns {Promise<PatchBuilderV2>} This builder instance for method chaining
497
566
  */
498
567
  async attachEdgeContent(from, to, label, content) {
568
+ this._assertNotCommitted();
569
+ // Validate identifiers before writing blob to avoid orphaned blobs
570
+ _assertNoReservedBytes(from, 'from');
571
+ _assertNoReservedBytes(to, 'to');
572
+ _assertNoReservedBytes(label, 'label');
573
+ _assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key');
499
574
  const oid = await this._persistence.writeBlob(content);
500
575
  this.setEdgeProperty(from, to, label, CONTENT_PROPERTY_KEY, oid);
501
576
  this._contentBlobs.push(oid);
@@ -521,14 +596,16 @@ export class PatchBuilderV2 {
521
596
  * - `ops`: Array of operations (NodeAdd, NodeRemove, EdgeAdd, EdgeRemove, PropSet)
522
597
  */
523
598
  build() {
524
- const schema = this._ops.some(op => op.type === 'PropSet' && op.node.charCodeAt(0) === 1) ? 3 : 2;
599
+ const schema = this._hasEdgeProps ? 3 : 2;
600
+ // Lower canonical ops to raw form for the persisted patch
601
+ const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
525
602
  return createPatchV2({
526
603
  schema,
527
604
  writer: this._writerId,
528
605
  lamport: this._lamport,
529
606
  context: vvSerialize(this._vv),
530
- ops: this._ops,
531
- reads: [...this._reads].sort(),
607
+ ops: rawOps,
608
+ reads: [...this._observedOperands].sort(),
532
609
  writes: [...this._writes].sort(),
533
610
  });
534
611
  }
@@ -537,20 +614,24 @@ export class PatchBuilderV2 {
537
614
  * Commits the patch to the graph.
538
615
  *
539
616
  * 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)
617
+ * 1. Verifies this builder is still open (not committed and not in-flight)
618
+ * 2. Validates the patch is non-empty
619
+ * 3. Checks for concurrent modifications (compare-and-swap on writer ref)
620
+ * 4. Calculates the next lamport timestamp from the parent commit
621
+ * 5. Builds the PatchV2 structure with the resolved lamport
622
+ * 6. Encodes the patch as CBOR and writes it as a Git blob
623
+ * 7. Creates a Git tree containing the patch blob
624
+ * 8. Creates a commit with proper trailers linking to the parent
625
+ * 9. Updates the writer ref to point to the new commit
626
+ * 10. Invokes the success callback if provided (for eager re-materialization)
549
627
  *
550
628
  * The commit is written to the writer's patch chain at:
551
629
  * `refs/warp/<graphName>/writers/<writerId>`
552
630
  *
553
631
  * @returns {Promise<string>} The commit SHA of the new patch commit
632
+ * @throws {Error} If this builder has already been committed, or a commit
633
+ * is currently in-flight on this builder.
634
+ * Message: `"PatchBuilder already committed — create a new builder"`
554
635
  * @throws {Error} If the patch is empty (no operations were added).
555
636
  * Message: `"Cannot commit empty patch: no operations added"`
556
637
  * @throws {WriterError} If a concurrent commit was detected (another process
@@ -578,115 +659,123 @@ export class PatchBuilderV2 {
578
659
  * }
579
660
  */
580
661
  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
- }
662
+ this._assertNotCommitted();
663
+ this._committing = true;
664
+ try {
665
+ // 2. Reject empty patches
666
+ if (this._ops.length === 0) {
667
+ throw new Error('Cannot commit empty patch: no operations added');
668
+ }
585
669
 
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
- }
670
+ // 3. Race detection: check if writer ref has advanced since builder creation
671
+ const writerRef = buildWriterRef(this._graphName, this._writerId);
672
+ const currentRefSha = await this._persistence.readRef(writerRef);
673
+
674
+ if (currentRefSha !== this._expectedParentSha) {
675
+ const err = /** @type {WriterError & { expectedSha: string|null, actualSha: string|null }} */ (new WriterError(
676
+ 'WRITER_CAS_CONFLICT',
677
+ 'Commit failed: writer ref was updated by another process. Re-materialize and retry.'
678
+ ));
679
+ err.expectedSha = this._expectedParentSha;
680
+ err.actualSha = currentRefSha;
681
+ throw err;
682
+ }
683
+
684
+ // 4. Calculate lamport and parent from current ref state.
685
+ // Start from this._lamport (set by _nextLamport() in createPatch()), which already
686
+ // incorporates the globally-observed max Lamport tick via _maxObservedLamport.
687
+ // This ensures a first-time writer whose own chain is empty still commits at a tick
688
+ // above any previously-observed writer, winning LWW tiebreakers correctly.
689
+ let lamport = this._lamport;
690
+ let parentCommit = null;
691
+
692
+ if (currentRefSha) {
693
+ parentCommit = currentRefSha;
694
+ // Read the current patch commit to get its lamport timestamp and take the max,
695
+ // so the chain stays monotonic even if the ref advanced since createPatch().
696
+ const commitMessage = await this._persistence.showNode(currentRefSha);
697
+ const kind = detectMessageKind(commitMessage);
698
+
699
+ if (kind === 'patch') {
700
+ let patchInfo;
701
+ try {
702
+ patchInfo = decodePatchMessage(commitMessage);
703
+ } catch (err) {
704
+ throw new Error(
705
+ `Failed to parse lamport from writer ref ${writerRef}: ` +
706
+ `commit ${currentRefSha} has invalid patch message format`,
707
+ { cause: err }
708
+ );
709
+ }
710
+ lamport = Math.max(this._lamport, patchInfo.lamport + 1);
711
+ }
712
+ // Non-patch ref (checkpoint, etc.): keep lamport from this._lamport
713
+ // (already incorporates _maxObservedLamport), matching _nextLamport() behavior.
714
+ }
599
715
 
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;
716
+ // 5. Build PatchV2 structure with correct lamport
717
+ // Note: Dots were assigned using constructor lamport, but commit lamport may differ.
718
+ // For now, we use the calculated lamport for the patch metadata.
719
+ // The dots themselves are independent of patch lamport (they use VV counters).
720
+ const schema = this._hasEdgeProps ? 3 : 2;
721
+ // Lower canonical ops to raw form for the persisted patch
722
+ const rawOps = /** @type {import('../types/WarpTypesV2.js').RawOpV2[]} */ (this._ops.map(lowerCanonicalOp));
723
+ const patch = createPatchV2({
724
+ schema,
725
+ writer: this._writerId,
726
+ lamport,
727
+ context: vvSerialize(this._vv),
728
+ ops: rawOps,
729
+ reads: [...this._observedOperands].sort(),
730
+ writes: [...this._writes].sort(),
731
+ });
732
+
733
+ // 6. Encode patch as CBOR and write as a Git blob
734
+ const patchCbor = this._codec.encode(patch);
735
+ const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor));
736
+
737
+ // 7. Create tree with the patch blob + any content blobs (deduplicated)
738
+ // Format for mktree: "mode type oid\tpath"
739
+ const treeEntries = [`100644 blob ${patchBlobOid}\tpatch.cbor`];
740
+ const uniqueBlobs = [...new Set(this._contentBlobs)];
741
+ for (const blobOid of uniqueBlobs) {
742
+ treeEntries.push(`100644 blob ${blobOid}\t_content_${blobOid}`);
743
+ }
744
+ const treeOid = await this._persistence.writeTree(treeEntries);
745
+
746
+ // 8. Create commit with proper trailers linking to the parent
747
+ const commitMessage = encodePatchMessage({
748
+ graph: this._graphName,
749
+ writer: this._writerId,
750
+ lamport,
751
+ patchOid: patchBlobOid,
752
+ schema,
753
+ });
754
+ const parents = parentCommit ? [parentCommit] : [];
755
+ const newCommitSha = await this._persistence.commitNodeWithTree({
756
+ treeOid,
757
+ parents,
758
+ message: commitMessage,
759
+ });
760
+
761
+ // 9. Update writer ref to point to new commit
762
+ await this._persistence.updateRef(writerRef, newCommitSha);
763
+
764
+ // 10. Notify success callback (updates graph's version vector + eager re-materialize)
765
+ if (this._onCommitSuccess) {
617
766
  try {
618
- patchInfo = decodePatchMessage(commitMessage);
767
+ await this._onCommitSuccess({ patch, sha: newCommitSha });
619
768
  } 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
- );
769
+ // Commit is already persisted — log but don't fail the caller.
770
+ this._logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, { error: err });
625
771
  }
626
- lamport = Math.max(this._lamport, patchInfo.lamport + 1);
627
772
  }
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
773
 
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
-
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
- }
774
+ this._committed = true;
775
+ return newCommitSha;
776
+ } finally {
777
+ this._committing = false;
687
778
  }
688
-
689
- return newCommitSha;
690
779
  }
691
780
 
692
781
  /**
@@ -723,22 +812,13 @@ export class PatchBuilderV2 {
723
812
  }
724
813
 
725
814
  /**
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
815
+ * Gets the set of observed operands (entities whose state was consulted).
816
+ * Retains the `reads` name for API/serialization compatibility.
817
+ * Internal field is `_observedOperands`.
818
+ * @returns {ReadonlySet<string>}
739
819
  */
740
820
  get reads() {
741
- return new Set(this._reads);
821
+ return new Set(this._observedOperands);
742
822
  }
743
823
 
744
824
  /**
@@ -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).
@@ -68,6 +68,8 @@ export function buildCanonicalPayload({ keyId, method, path, timestamp, nonce, c
68
68
  */
69
69
  export async function signSyncRequest({ method, path, contentType, body, secret, keyId }, { crypto } = {}) {
70
70
  const c = crypto || defaultCrypto;
71
+ // Wall-clock timestamp required for HMAC replay protection (not a perf timer)
72
+ // eslint-disable-next-line no-restricted-syntax
71
73
  const timestamp = String(Date.now());
72
74
  const nonce = globalThis.crypto.randomUUID();
73
75
 
@@ -187,6 +189,7 @@ export default class SyncAuthService {
187
189
  this._mode = mode;
188
190
  this._crypto = crypto || defaultCrypto;
189
191
  this._logger = logger || nullLogger;
192
+ // eslint-disable-next-line no-restricted-syntax -- wall-clock fallback for HMAC verification
190
193
  this._wallClockMs = wallClockMs || (() => Date.now());
191
194
  this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
192
195
  this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);