@git-stunts/git-warp 12.2.1 → 12.4.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 (121) hide show
  1. package/README.md +5 -5
  2. package/bin/cli/commands/info.js +1 -5
  3. package/bin/cli/infrastructure.js +6 -9
  4. package/bin/cli/shared.js +8 -0
  5. package/bin/presenters/text.js +10 -3
  6. package/bin/warp-graph.js +6 -6
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +5 -35
  9. package/src/domain/crdt/ORSet.js +3 -0
  10. package/src/domain/crdt/VersionVector.js +1 -1
  11. package/src/domain/entities/GraphNode.js +1 -6
  12. package/src/domain/errors/ForkError.js +1 -1
  13. package/src/domain/errors/IndexError.js +1 -1
  14. package/src/domain/errors/OperationAbortedError.js +1 -1
  15. package/src/domain/errors/PatchError.js +1 -1
  16. package/src/domain/errors/PersistenceError.js +45 -0
  17. package/src/domain/errors/QueryError.js +1 -1
  18. package/src/domain/errors/SchemaUnsupportedError.js +1 -1
  19. package/src/domain/errors/SyncError.js +1 -1
  20. package/src/domain/errors/TraversalError.js +1 -1
  21. package/src/domain/errors/TrustError.js +1 -1
  22. package/src/domain/errors/WormholeError.js +1 -1
  23. package/src/domain/errors/index.js +1 -0
  24. package/src/domain/services/AdjacencyNeighborProvider.js +1 -4
  25. package/src/domain/services/AnchorMessageCodec.js +1 -3
  26. package/src/domain/services/AuditMessageCodec.js +1 -5
  27. package/src/domain/services/AuditReceiptService.js +4 -18
  28. package/src/domain/services/AuditVerifierService.js +3 -7
  29. package/src/domain/services/BitmapIndexBuilder.js +6 -12
  30. package/src/domain/services/BitmapIndexReader.js +7 -20
  31. package/src/domain/services/BitmapNeighborProvider.js +1 -3
  32. package/src/domain/services/BoundaryTransitionRecord.js +7 -23
  33. package/src/domain/services/CheckpointMessageCodec.js +6 -6
  34. package/src/domain/services/CheckpointSerializerV5.js +8 -12
  35. package/src/domain/services/CheckpointService.js +28 -40
  36. package/src/domain/services/CommitDagTraversalService.js +1 -3
  37. package/src/domain/services/DagPathFinding.js +9 -59
  38. package/src/domain/services/DagTopology.js +4 -16
  39. package/src/domain/services/DagTraversal.js +7 -31
  40. package/src/domain/services/Frontier.js +4 -6
  41. package/src/domain/services/GitLogParser.js +1 -2
  42. package/src/domain/services/GraphTraversal.js +14 -114
  43. package/src/domain/services/HealthCheckService.js +3 -9
  44. package/src/domain/services/HookInstaller.js +2 -8
  45. package/src/domain/services/HttpSyncServer.js +24 -25
  46. package/src/domain/services/IncrementalIndexUpdater.js +4 -6
  47. package/src/domain/services/IndexRebuildService.js +6 -52
  48. package/src/domain/services/IndexStalenessChecker.js +2 -3
  49. package/src/domain/services/JoinReducer.js +200 -100
  50. package/src/domain/services/KeyCodec.js +48 -0
  51. package/src/domain/services/LogicalBitmapIndexBuilder.js +1 -2
  52. package/src/domain/services/LogicalIndexBuildService.js +2 -6
  53. package/src/domain/services/LogicalIndexReader.js +1 -2
  54. package/src/domain/services/LogicalTraversal.js +13 -64
  55. package/src/domain/services/MaterializedViewService.js +5 -19
  56. package/src/domain/services/MessageSchemaDetector.js +35 -5
  57. package/src/domain/services/MigrationService.js +1 -4
  58. package/src/domain/services/ObserverView.js +1 -7
  59. package/src/domain/services/OpNormalizer.js +79 -0
  60. package/src/domain/services/PatchBuilderV2.js +67 -38
  61. package/src/domain/services/PatchMessageCodec.js +1 -6
  62. package/src/domain/services/PropertyIndexBuilder.js +1 -2
  63. package/src/domain/services/PropertyIndexReader.js +1 -4
  64. package/src/domain/services/ProvenanceIndex.js +5 -7
  65. package/src/domain/services/ProvenancePayload.js +1 -1
  66. package/src/domain/services/QueryBuilder.js +3 -16
  67. package/src/domain/services/StateDiff.js +3 -9
  68. package/src/domain/services/StateSerializerV5.js +10 -10
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +13 -41
  70. package/src/domain/services/SyncAuthService.js +8 -32
  71. package/src/domain/services/SyncController.js +5 -25
  72. package/src/domain/services/SyncProtocol.js +10 -13
  73. package/src/domain/services/SyncTrustGate.js +4 -9
  74. package/src/domain/services/TemporalQuery.js +9 -27
  75. package/src/domain/services/TranslationCost.js +2 -8
  76. package/src/domain/services/WarpMessageCodec.js +2 -0
  77. package/src/domain/services/WarpStateIndexBuilder.js +2 -4
  78. package/src/domain/services/WormholeService.js +9 -25
  79. package/src/domain/trust/TrustCrypto.js +9 -10
  80. package/src/domain/trust/TrustEvaluator.js +1 -8
  81. package/src/domain/trust/TrustRecordService.js +5 -10
  82. package/src/domain/types/TickReceipt.js +9 -11
  83. package/src/domain/types/WarpTypes.js +1 -5
  84. package/src/domain/types/WarpTypesV2.js +78 -13
  85. package/src/domain/utils/CachedValue.js +1 -4
  86. package/src/domain/utils/MinHeap.js +3 -3
  87. package/src/domain/utils/RefLayout.js +26 -0
  88. package/src/domain/utils/WriterId.js +2 -7
  89. package/src/domain/utils/canonicalCbor.js +1 -1
  90. package/src/domain/utils/defaultClock.js +1 -0
  91. package/src/domain/utils/defaultCodec.js +1 -1
  92. package/src/domain/utils/parseCursorBlob.js +4 -4
  93. package/src/domain/warp/PatchSession.js +3 -8
  94. package/src/domain/warp/Writer.js +9 -12
  95. package/src/domain/warp/_wire.js +2 -2
  96. package/src/domain/warp/_wiredMethods.d.ts +5 -7
  97. package/src/domain/warp/checkpoint.methods.js +1 -1
  98. package/src/domain/warp/fork.methods.js +2 -6
  99. package/src/domain/warp/materializeAdvanced.methods.js +3 -3
  100. package/src/domain/warp/patch.methods.js +8 -8
  101. package/src/domain/warp/provenance.methods.js +5 -5
  102. package/src/domain/warp/query.methods.js +9 -18
  103. package/src/domain/warp/subscribe.methods.js +2 -8
  104. package/src/globals.d.ts +7 -0
  105. package/src/infrastructure/adapters/BunHttpAdapter.js +14 -18
  106. package/src/infrastructure/adapters/ConsoleLogger.js +2 -9
  107. package/src/infrastructure/adapters/DenoHttpAdapter.js +15 -15
  108. package/src/infrastructure/adapters/GitGraphAdapter.js +234 -58
  109. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +9 -2
  110. package/src/infrastructure/adapters/NodeHttpAdapter.js +14 -14
  111. package/src/infrastructure/adapters/WebCryptoAdapter.js +1 -2
  112. package/src/ports/BlobPort.js +2 -2
  113. package/src/ports/HttpServerPort.js +24 -2
  114. package/src/ports/RefPort.js +2 -1
  115. package/src/visualization/renderers/ascii/box.js +1 -1
  116. package/src/visualization/renderers/ascii/check.js +1 -5
  117. package/src/visualization/renderers/ascii/history.js +1 -6
  118. package/src/visualization/renderers/ascii/path.js +4 -22
  119. package/src/visualization/renderers/ascii/progress.js +1 -4
  120. package/src/visualization/renderers/ascii/seek.js +1 -5
  121. package/src/visualization/renderers/ascii/table.js +1 -3
@@ -15,7 +15,8 @@ import { lwwSet, lwwMax } from '../crdt/LWW.js';
15
15
  import { createEventId, compareEventIds } from '../utils/EventId.js';
16
16
  import { createTickReceipt, OP_TYPES } from '../types/TickReceipt.js';
17
17
  import { encodeDot } from '../crdt/Dot.js';
18
- import { encodeEdgeKey, decodeEdgeKey, encodePropKey } from './KeyCodec.js';
18
+ import { encodeEdgeKey, decodeEdgeKey, encodePropKey, encodeEdgePropKey, EDGE_PROP_PREFIX } from './KeyCodec.js';
19
+ import { normalizeRawOp } from './OpNormalizer.js';
19
20
  import { createEmptyDiff, mergeDiffs } from '../types/PatchDiff.js';
20
21
  import PatchError from '../errors/PatchError.js';
21
22
 
@@ -27,6 +28,9 @@ export {
27
28
  encodeEdgePropKey, isEdgePropKey, decodeEdgePropKey,
28
29
  } from './KeyCodec.js';
29
30
 
31
+ // Re-export op normalization for consumers that operate on raw patches
32
+ export { normalizeRawOp, lowerCanonicalOp } from './OpNormalizer.js';
33
+
30
34
  /**
31
35
  * @typedef {Object} WarpStateV5
32
36
  * @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes
@@ -40,6 +44,28 @@ export {
40
44
  * always produces an empty Map for them.
41
45
  */
42
46
 
47
+ /**
48
+ * @typedef {Object} OpLike
49
+ * @property {string} type - Operation type discriminator
50
+ * @property {string} [node] - Node ID (for NodeAdd, NodeRemove, PropSet)
51
+ * @property {import('../crdt/Dot.js').Dot} [dot] - Dot identifier (for NodeAdd, EdgeAdd)
52
+ * @property {string[]} [observedDots] - Encoded dots to remove (for NodeRemove, EdgeRemove)
53
+ * @property {string} [from] - Source node ID (for EdgeAdd, EdgeRemove)
54
+ * @property {string} [to] - Target node ID (for EdgeAdd, EdgeRemove)
55
+ * @property {string} [label] - Edge label (for EdgeAdd, EdgeRemove)
56
+ * @property {string} [key] - Property key (for PropSet)
57
+ * @property {unknown} [value] - Property value (for PropSet)
58
+ * @property {string} [oid] - Blob object ID (for BlobValue)
59
+ */
60
+
61
+ /**
62
+ * @typedef {Object} PatchLike
63
+ * @property {string} writer - Writer ID who created this patch
64
+ * @property {number} lamport - Lamport timestamp of this patch
65
+ * @property {OpLike[]} ops - Ordered array of operations
66
+ * @property {Map<string, number>|Record<string, number>} context - Version vector context
67
+ */
68
+
43
69
  /**
44
70
  * Creates an empty V5 state with all CRDT structures initialized.
45
71
  *
@@ -78,33 +104,63 @@ export function createEmptyStateV5() {
78
104
  * clone the state first using `cloneStateV5()`.
79
105
  *
80
106
  * @param {WarpStateV5} state - The state to mutate. Modified in place.
81
- * @param {Object} op - The operation to apply
82
- * @param {string} op.type - One of: 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue'
83
- * @param {string} [op.node] - Node ID (for NodeAdd, NodeRemove, PropSet)
84
- * @param {import('../crdt/Dot.js').Dot} [op.dot] - Dot identifier (for NodeAdd, EdgeAdd)
85
- * @param {string[]} [op.observedDots] - Encoded dots to remove (for NodeRemove, EdgeRemove)
86
- * @param {string} [op.from] - Source node ID (for EdgeAdd, EdgeRemove)
87
- * @param {string} [op.to] - Target node ID (for EdgeAdd, EdgeRemove)
88
- * @param {string} [op.label] - Edge label (for EdgeAdd, EdgeRemove)
89
- * @param {string} [op.key] - Property key (for PropSet)
90
- * @param {unknown} [op.value] - Property value (for PropSet)
107
+ * @param {OpLike} op - The operation to apply
91
108
  * @param {import('../utils/EventId.js').EventId} eventId - Event ID for causality tracking
92
109
  * @returns {void}
93
110
  */
94
111
  /**
95
- * Known V2 operation types. Used for forward-compatibility validation.
112
+ * Known raw (wire-format) V2 operation types. These are the 6 types that
113
+ * appear in persisted patches and on the sync wire.
114
+ * @type {ReadonlySet<string>}
115
+ */
116
+ export const RAW_KNOWN_OPS = new Set([
117
+ 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
118
+ 'PropSet', 'BlobValue',
119
+ ]);
120
+
121
+ /**
122
+ * Known canonical (internal) V2 operation types. Includes the 6 raw types
123
+ * plus the ADR 1 canonical split types `NodePropSet` and `EdgePropSet`.
96
124
  * @type {ReadonlySet<string>}
97
125
  */
98
- const KNOWN_OPS = new Set(['NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue']);
126
+ export const CANONICAL_KNOWN_OPS = new Set([
127
+ 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
128
+ 'PropSet', 'NodePropSet', 'EdgePropSet', 'BlobValue',
129
+ ]);
130
+
131
+ /**
132
+ * Validates that an operation has a known raw (wire-format) type.
133
+ * Use this at sync/wire boundaries to fail-close on unknown or
134
+ * canonical-only types arriving over the wire.
135
+ *
136
+ * @param {{ type: string }} op
137
+ * @returns {boolean} True if the op type is in RAW_KNOWN_OPS
138
+ */
139
+ export function isKnownRawOp(op) {
140
+ return Boolean(op && typeof op.type === 'string' && RAW_KNOWN_OPS.has(op.type));
141
+ }
142
+
143
+ /**
144
+ * Validates that an operation has a known canonical (internal) type.
145
+ * Use this for internal guards after normalization.
146
+ *
147
+ * @param {{ type: string }} op
148
+ * @returns {boolean} True if the op type is in CANONICAL_KNOWN_OPS
149
+ */
150
+ export function isKnownCanonicalOp(op) {
151
+ return Boolean(op && typeof op.type === 'string' && CANONICAL_KNOWN_OPS.has(op.type));
152
+ }
99
153
 
100
154
  /**
101
155
  * Validates that an operation has a known type.
102
156
  *
157
+ * @deprecated Use {@link isKnownRawOp} for wire validation or
158
+ * {@link isKnownCanonicalOp} for internal guards.
103
159
  * @param {{ type: string }} op
104
- * @returns {boolean} True if the op type is in KNOWN_OPS
160
+ * @returns {boolean} True if the op type is a known raw wire type
105
161
  */
106
162
  export function isKnownOp(op) {
107
- return op && typeof op.type === 'string' && KNOWN_OPS.has(op.type);
163
+ return isKnownRawOp(op);
108
164
  }
109
165
 
110
166
  /**
@@ -206,6 +262,16 @@ function validateOp(op) {
206
262
  requireString(op, 'node');
207
263
  requireString(op, 'key');
208
264
  break;
265
+ case 'NodePropSet':
266
+ requireString(op, 'node');
267
+ requireString(op, 'key');
268
+ break;
269
+ case 'EdgePropSet':
270
+ requireString(op, 'from');
271
+ requireString(op, 'to');
272
+ requireString(op, 'label');
273
+ requireString(op, 'key');
274
+ break;
209
275
  default:
210
276
  // BlobValue and unknown types: no validation (forward-compat)
211
277
  break;
@@ -245,8 +311,34 @@ export function applyOpV2(state, op, eventId) {
245
311
  case 'EdgeRemove':
246
312
  orsetRemove(state.edgeAlive, /** @type {Set<string>} */ (/** @type {unknown} */ (op.observedDots)));
247
313
  break;
314
+ case 'NodePropSet': {
315
+ const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
316
+ const current = state.prop.get(key);
317
+ state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
318
+ break;
319
+ }
320
+ case 'EdgePropSet': {
321
+ const key = encodeEdgePropKey(
322
+ /** @type {string} */ (op.from),
323
+ /** @type {string} */ (op.to),
324
+ /** @type {string} */ (op.label),
325
+ /** @type {string} */ (op.key),
326
+ );
327
+ const current = state.prop.get(key);
328
+ state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
329
+ break;
330
+ }
248
331
  case 'PropSet': {
249
- // Uses EventId-based LWW, same as v4
332
+ // Legacy raw PropSet — must NOT carry edge-property encoding at this point.
333
+ // If it does, normalization was skipped.
334
+ if (typeof op.node === 'string' && op.node[0] === EDGE_PROP_PREFIX) {
335
+ throw new PatchError(
336
+ 'Unnormalized legacy edge-property PropSet reached canonical apply path. ' +
337
+ 'Call normalizeRawOp() at the decode boundary.',
338
+ { context: { opType: 'PropSet', node: op.node } },
339
+ );
340
+ }
341
+ // Plain node property (backward-compat for callers that bypass normalization)
250
342
  const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
251
343
  const current = state.prop.get(key);
252
344
  state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
@@ -270,7 +362,7 @@ export function applyOpV2(state, op, eventId) {
270
362
  * - EdgeRemove -> EdgeTombstone (CRDT tombstone semantics)
271
363
  * - All others pass through unchanged
272
364
  *
273
- * @const {Object<string, string>}
365
+ * @const {Record<string, string>}
274
366
  */
275
367
  const RECEIPT_OP_TYPE = {
276
368
  NodeAdd: 'NodeAdd',
@@ -278,6 +370,8 @@ const RECEIPT_OP_TYPE = {
278
370
  EdgeAdd: 'EdgeAdd',
279
371
  EdgeRemove: 'EdgeTombstone',
280
372
  PropSet: 'PropSet',
373
+ NodePropSet: 'NodePropSet',
374
+ EdgePropSet: 'EdgePropSet',
281
375
  BlobValue: 'BlobValue',
282
376
  };
283
377
 
@@ -295,9 +389,7 @@ const VALID_RECEIPT_OPS = new Set(OP_TYPES);
295
389
  * this add operation is effective or redundant (idempotent re-delivery).
296
390
  *
297
391
  * @param {import('../crdt/ORSet.js').ORSet} orset - The node OR-Set containing alive nodes
298
- * @param {Object} op - The NodeAdd operation
299
- * @param {string} op.node - The node ID being added
300
- * @param {import('../crdt/Dot.js').Dot} op.dot - The dot uniquely identifying this add event
392
+ * @param {{node: string, dot: import('../crdt/Dot.js').Dot}} op - The NodeAdd operation
301
393
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID as target
302
394
  */
303
395
  function nodeAddOutcome(orset, op) {
@@ -318,9 +410,7 @@ function nodeAddOutcome(orset, op) {
318
410
  * observed at the time the remove was issued.
319
411
  *
320
412
  * @param {import('../crdt/ORSet.js').ORSet} orset - The node OR-Set containing alive nodes
321
- * @param {Object} op - The NodeRemove operation
322
- * @param {string} [op.node] - The node ID being removed (may be absent for dot-only removes)
323
- * @param {string[]} op.observedDots - Array of encoded dots that were observed when the remove was issued
413
+ * @param {{node?: string, observedDots: string[] | Set<string>}} op - The NodeRemove operation
324
414
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID (or '*') as target
325
415
  */
326
416
  function nodeRemoveOutcome(orset, op) {
@@ -350,11 +440,7 @@ function nodeRemoveOutcome(orset, op) {
350
440
  * Unlike nodes, edges are keyed by the composite (from, to, label) tuple.
351
441
  *
352
442
  * @param {import('../crdt/ORSet.js').ORSet} orset - The edge OR-Set containing alive edges
353
- * @param {Object} op - The EdgeAdd operation
354
- * @param {string} op.from - Source node ID
355
- * @param {string} op.to - Target node ID
356
- * @param {string} op.label - Edge label
357
- * @param {import('../crdt/Dot.js').Dot} op.dot - The dot uniquely identifying this add event
443
+ * @param {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} op - The EdgeAdd operation
358
444
  * @param {string} edgeKey - Pre-encoded edge key (from\0to\0label format)
359
445
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key as target
360
446
  */
@@ -379,11 +465,7 @@ function edgeAddOutcome(orset, op, edgeKey) {
379
465
  * otherwise falls back to '*' for wildcard/unknown targets.
380
466
  *
381
467
  * @param {import('../crdt/ORSet.js').ORSet} orset - The edge OR-Set containing alive edges
382
- * @param {Object} op - The EdgeRemove operation
383
- * @param {string} [op.from] - Source node ID (optional for computing target)
384
- * @param {string} [op.to] - Target node ID (optional for computing target)
385
- * @param {string} [op.label] - Edge label (optional for computing target)
386
- * @param {string[]} op.observedDots - Array of encoded dots that were observed when the remove was issued
468
+ * @param {{from?: string, to?: string, label?: string, observedDots: string[] | Set<string>}} op - The EdgeRemove operation
387
469
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key (or '*') as target
388
470
  */
389
471
  function edgeRemoveOutcome(orset, op) {
@@ -409,7 +491,7 @@ function edgeRemoveOutcome(orset, op) {
409
491
  }
410
492
 
411
493
  /**
412
- * Determines the receipt outcome for a PropSet operation.
494
+ * Determines the receipt outcome for a property operation given a pre-computed key.
413
495
  *
414
496
  * Uses LWW (Last-Write-Wins) semantics to determine whether the incoming property
415
497
  * value wins over any existing value. The comparison is based on EventId ordering:
@@ -423,41 +505,55 @@ function edgeRemoveOutcome(orset, op) {
423
505
  * - `redundant`: Exact same write (identical EventId)
424
506
  *
425
507
  * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap - The properties map keyed by encoded prop keys
426
- * @param {Object} op - The PropSet operation
427
- * @param {string} op.node - Node ID owning the property
428
- * @param {string} op.key - Property key/name
429
- * @param {unknown} op.value - Property value to set
508
+ * @param {string} key - Pre-encoded property key (node or edge)
430
509
  * @param {import('../utils/EventId.js').EventId} eventId - The event ID for this operation, used for LWW comparison
431
510
  * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
432
511
  * Outcome with encoded prop key as target; includes reason when superseded
433
512
  */
434
- function propSetOutcome(propMap, op, eventId) {
435
- const key = encodePropKey(op.node, op.key);
513
+ function propOutcomeForKey(propMap, key, eventId) {
436
514
  const current = propMap.get(key);
437
- const target = key;
438
515
 
439
516
  if (!current) {
440
- // No existing value -- this write wins
441
- return { target, result: 'applied' };
517
+ return { target: key, result: 'applied' };
442
518
  }
443
519
 
444
- // Compare the incoming EventId with the existing register's EventId
445
520
  const cmp = compareEventIds(eventId, current.eventId);
446
521
  if (cmp > 0) {
447
- // Incoming write wins
448
- return { target, result: 'applied' };
522
+ return { target: key, result: 'applied' };
449
523
  }
450
524
  if (cmp < 0) {
451
- // Existing write wins
452
525
  const winner = current.eventId;
453
526
  return {
454
- target,
527
+ target: key,
455
528
  result: 'superseded',
456
529
  reason: `LWW: writer ${winner.writerId} at lamport ${winner.lamport} wins`,
457
530
  };
458
531
  }
459
- // Same EventId -- redundant (exact same write)
460
- return { target, result: 'redundant' };
532
+ return { target: key, result: 'redundant' };
533
+ }
534
+
535
+ /**
536
+ * Determines the receipt outcome for a PropSet/NodePropSet operation.
537
+ *
538
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
539
+ * @param {{node: string, key: string}} op - The PropSet or NodePropSet operation
540
+ * @param {import('../utils/EventId.js').EventId} eventId
541
+ * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
542
+ */
543
+ function propSetOutcome(propMap, op, eventId) {
544
+ return propOutcomeForKey(propMap, encodePropKey(op.node, op.key), eventId);
545
+ }
546
+
547
+ /**
548
+ * Determines the receipt outcome for an EdgePropSet operation.
549
+ *
550
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
551
+ * @param {{from: string, to: string, label: string, key: string}} op - The EdgePropSet operation
552
+ * @param {import('../utils/EventId.js').EventId} eventId
553
+ * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
554
+ */
555
+ function edgePropSetOutcome(propMap, op, eventId) {
556
+ return propOutcomeForKey(propMap, encodeEdgePropKey(op.from, op.to, op.label, op.key), eventId);
461
557
  }
462
558
 
463
559
  /**
@@ -476,10 +572,7 @@ function foldPatchDot(frontier, writer, lamport) {
476
572
  /**
477
573
  * Merges a patch's context into state and folds the patch dot.
478
574
  * @param {WarpStateV5} state
479
- * @param {Object} patch
480
- * @param {string} patch.writer
481
- * @param {number} patch.lamport
482
- * @param {Map<string, number>|{[x: string]: number}} patch.context
575
+ * @param {{writer: string, lamport: number, context: Map<string, number>|Record<string, number>}} patch
483
576
  */
484
577
  function updateFrontierFromPatch(state, patch) {
485
578
  const contextVV = patch.context instanceof Map
@@ -493,18 +586,14 @@ function updateFrontierFromPatch(state, patch) {
493
586
  * Applies a patch to state without receipt collection (zero overhead).
494
587
  *
495
588
  * @param {WarpStateV5} state - The state to mutate in place
496
- * @param {Object} patch - The patch to apply
497
- * @param {string} patch.writer
498
- * @param {number} patch.lamport
499
- * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops
500
- * @param {Map<string, number>|{[x: string]: number}} patch.context
589
+ * @param {PatchLike} patch - The patch to apply
501
590
  * @param {string} patchSha - Git SHA of the patch commit
502
591
  * @returns {WarpStateV5} The mutated state
503
592
  */
504
593
  export function applyFast(state, patch, patchSha) {
505
594
  for (let i = 0; i < patch.ops.length; i++) {
506
595
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
507
- applyOpV2(state, patch.ops[i], eventId);
596
+ applyOpV2(state, normalizeRawOp(patch.ops[i]), eventId);
508
597
  }
509
598
  updateFrontierFromPatch(state, patch);
510
599
  return state;
@@ -599,11 +688,17 @@ function snapshotBeforeOp(state, op) {
599
688
  const aliveBeforeEdges = aliveElementsForDots(state.edgeAlive, edgeDots);
600
689
  return { aliveBeforeEdges };
601
690
  }
602
- case 'PropSet': {
691
+ case 'PropSet':
692
+ case 'NodePropSet': {
603
693
  const pk = encodePropKey(op.node, op.key);
604
694
  const reg = state.prop.get(pk);
605
695
  return { prevPropValue: reg ? reg.value : undefined, propKey: pk };
606
696
  }
697
+ case 'EdgePropSet': {
698
+ const epk = encodeEdgePropKey(op.from, op.to, op.label, op.key);
699
+ const ereg = state.prop.get(epk);
700
+ return { prevPropValue: ereg ? ereg.value : undefined, propKey: epk };
701
+ }
607
702
  default:
608
703
  return {};
609
704
  }
@@ -640,7 +735,8 @@ function accumulateOpDiff(diff, state, op, before) {
640
735
  collectEdgeRemovals(diff, state, before);
641
736
  break;
642
737
  }
643
- case 'PropSet': {
738
+ case 'PropSet':
739
+ case 'NodePropSet': {
644
740
  const reg = state.prop.get(/** @type {string} */ (before.propKey));
645
741
  const newVal = reg ? reg.value : undefined;
646
742
  if (newVal !== before.prevPropValue) {
@@ -653,6 +749,19 @@ function accumulateOpDiff(diff, state, op, before) {
653
749
  }
654
750
  break;
655
751
  }
752
+ case 'EdgePropSet': {
753
+ const ereg = state.prop.get(/** @type {string} */ (before.propKey));
754
+ const eNewVal = ereg ? ereg.value : undefined;
755
+ if (eNewVal !== before.prevPropValue) {
756
+ diff.propsChanged.push({
757
+ nodeId: encodeEdgeKey(op.from, op.to, op.label),
758
+ key: op.key,
759
+ value: eNewVal,
760
+ prevValue: before.prevPropValue,
761
+ });
762
+ }
763
+ break;
764
+ }
656
765
  default:
657
766
  break;
658
767
  }
@@ -698,11 +807,7 @@ function collectEdgeRemovals(diff, state, before) {
698
807
  * winner changes. Redundant ops produce no diff entries.
699
808
  *
700
809
  * @param {WarpStateV5} state - The state to mutate in place
701
- * @param {Object} patch - The patch to apply
702
- * @param {string} patch.writer
703
- * @param {number} patch.lamport
704
- * @param {Array<Object>} patch.ops
705
- * @param {Map<string, number>|{[x: string]: number}} patch.context
810
+ * @param {PatchLike} patch - The patch to apply
706
811
  * @param {string} patchSha - Git SHA of the patch commit
707
812
  * @returns {{state: WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}}
708
813
  */
@@ -710,13 +815,12 @@ export function applyWithDiff(state, patch, patchSha) {
710
815
  const diff = createEmptyDiff();
711
816
 
712
817
  for (let i = 0; i < patch.ops.length; i++) {
713
- const op = patch.ops[i];
714
- validateOp(/** @type {Record<string, unknown>} */ (op));
818
+ const canonOp = /** @type {import('../types/WarpTypesV2.js').CanonicalOpV2} */ (normalizeRawOp(patch.ops[i]));
819
+ validateOp(/** @type {Record<string, unknown>} */ (canonOp));
715
820
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
716
- const typedOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (op);
717
- const before = snapshotBeforeOp(state, typedOp);
718
- applyOpV2(state, typedOp, eventId);
719
- accumulateOpDiff(diff, state, typedOp, before);
821
+ const before = snapshotBeforeOp(state, canonOp);
822
+ applyOpV2(state, canonOp, eventId);
823
+ accumulateOpDiff(diff, state, canonOp, before);
720
824
  }
721
825
 
722
826
  updateFrontierFromPatch(state, patch);
@@ -727,11 +831,7 @@ export function applyWithDiff(state, patch, patchSha) {
727
831
  * Applies a patch to state with receipt collection for provenance tracking.
728
832
  *
729
833
  * @param {WarpStateV5} state - The state to mutate in place
730
- * @param {Object} patch - The patch to apply
731
- * @param {string} patch.writer
732
- * @param {number} patch.lamport
733
- * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops
734
- * @param {Map<string, number>|{[x: string]: number}} patch.context
834
+ * @param {PatchLike} patch - The patch to apply
735
835
  * @param {string} patchSha - Git SHA of the patch commit
736
836
  * @returns {{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
737
837
  */
@@ -739,41 +839,47 @@ export function applyWithReceipt(state, patch, patchSha) {
739
839
  /** @type {import('../types/TickReceipt.js').OpOutcome[]} */
740
840
  const opResults = [];
741
841
  for (let i = 0; i < patch.ops.length; i++) {
742
- const op = patch.ops[i];
743
- validateOp(/** @type {Record<string, unknown>} */ (op));
842
+ const canonOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (normalizeRawOp(patch.ops[i]));
843
+ validateOp(/** @type {Record<string, unknown>} */ (canonOp));
744
844
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
745
845
 
746
846
  // Determine outcome BEFORE applying the op (state is pre-op)
747
847
  /** @type {{target: string, result: string, reason?: string}} */
748
848
  let outcome;
749
- switch (op.type) {
849
+ switch (canonOp.type) {
750
850
  case 'NodeAdd':
751
- outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (op));
851
+ outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp));
752
852
  break;
753
853
  case 'NodeRemove':
754
- outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (op));
854
+ outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (canonOp));
755
855
  break;
756
856
  case 'EdgeAdd': {
757
- const edgeKey = encodeEdgeKey(/** @type {string} */ (op.from), /** @type {string} */ (op.to), /** @type {string} */ (op.label));
758
- outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (op), edgeKey);
857
+ const edgeKey = encodeEdgeKey(canonOp.from, canonOp.to, canonOp.label);
858
+ outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp), edgeKey);
759
859
  break;
760
860
  }
761
861
  case 'EdgeRemove':
762
- outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (op));
862
+ outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (canonOp));
763
863
  break;
764
864
  case 'PropSet':
765
- outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (op), eventId);
865
+ case 'NodePropSet':
866
+ outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: unknown}} */ (canonOp), eventId);
766
867
  break;
767
- default:
868
+ case 'EdgePropSet':
869
+ outcome = edgePropSetOutcome(state.prop, /** @type {{from: string, to: string, label: string, key: string, value: unknown}} */ (canonOp), eventId);
870
+ break;
871
+ default: {
768
872
  // Unknown or BlobValue — always applied
769
- outcome = { target: op.node || op.oid || '*', result: 'applied' };
873
+ const anyOp = /** @type {Record<string, string>} */ (canonOp);
874
+ outcome = { target: anyOp.node || anyOp.oid || '*', result: 'applied' };
770
875
  break;
876
+ }
771
877
  }
772
878
 
773
879
  // Apply the op (mutates state)
774
- applyOpV2(state, op, eventId);
880
+ applyOpV2(state, canonOp, eventId);
775
881
 
776
- const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[op.type] || op.type;
882
+ const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[canonOp.type] || canonOp.type;
777
883
  // Skip unknown/forward-compatible op types that aren't valid receipt ops
778
884
  if (!VALID_RECEIPT_OPS.has(receiptOp)) {
779
885
  continue;
@@ -814,11 +920,7 @@ export function applyWithReceipt(state, patch, patchSha) {
814
920
  * clone the state first using `cloneStateV5()`.
815
921
  *
816
922
  * @param {WarpStateV5} state - The state to mutate. Modified in place.
817
- * @param {Object} patch - The patch to apply
818
- * @param {string} patch.writer - Writer ID who created this patch
819
- * @param {number} patch.lamport - Lamport timestamp of this patch
820
- * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>} patch.ops - Array of operations to apply
821
- * @param {Map<string, number>|{[x: string]: number}} patch.context - Version vector context (Map or serialized form)
923
+ * @param {PatchLike} patch - The patch to apply
822
924
  * @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation)
823
925
  * @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data
824
926
  * @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
@@ -927,11 +1029,9 @@ function mergeEdgeBirthEvent(a, b) {
927
1029
  * - When `options.receipts` is true, returns a TickReceipt per patch for
928
1030
  * provenance tracking and debugging.
929
1031
  *
930
- * @param {Array<{patch: {writer: string, lamport: number, ops: Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: unknown, oid?: string}>, context: Map<string, number>|{[x: string]: number}}, sha: string}>} patches - Array of patch objects with their Git SHAs
1032
+ * @param {Array<{patch: PatchLike, sha: string}>} patches - Array of patch objects with their Git SHAs
931
1033
  * @param {WarpStateV5} [initialState] - Optional starting state (for incremental materialization from checkpoint)
932
- * @param {Object} [options] - Optional configuration
933
- * @param {boolean} [options.receipts=false] - When true, collect and return TickReceipts
934
- * @param {boolean} [options.trackDiff=false] - When true, collect and return PatchDiff
1034
+ * @param {{receipts?: boolean, trackDiff?: boolean}} [options] - Optional configuration
935
1035
  * @returns {WarpStateV5|{state: WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}|{state: WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}}
936
1036
  * Returns state directly when no options;
937
1037
  * returns {state, receipts} when receipts is true;
@@ -91,6 +91,54 @@ export function encodeEdgePropKey(from, to, label, propKey) {
91
91
  return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}\0${propKey}`;
92
92
  }
93
93
 
94
+ // -------------------------------------------------------------------------
95
+ // Legacy edge-property node encoding (raw PropSet ↔ canonical EdgePropSet)
96
+ // -------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Encodes edge identity as the legacy `node` field value for raw PropSet ops.
100
+ *
101
+ * Format: `\x01from\0to\0label`
102
+ *
103
+ * @param {string} from - Source node ID
104
+ * @param {string} to - Target node ID
105
+ * @param {string} label - Edge label
106
+ * @returns {string}
107
+ */
108
+ export function encodeLegacyEdgePropNode(from, to, label) {
109
+ return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`;
110
+ }
111
+
112
+ /**
113
+ * Returns true if a raw PropSet `node` field encodes an edge identity.
114
+ * @param {string} node - The `node` field from a raw PropSet op
115
+ * @returns {boolean}
116
+ */
117
+ export function isLegacyEdgePropNode(node) {
118
+ return typeof node === 'string' && node.length > 0 && node[0] === EDGE_PROP_PREFIX;
119
+ }
120
+
121
+ /**
122
+ * Decodes a legacy edge-property `node` field back to its components.
123
+ * @param {string} node - The `node` field (must start with \x01)
124
+ * @returns {{from: string, to: string, label: string}}
125
+ * @throws {Error} If the node field is not a valid legacy edge-property encoding
126
+ */
127
+ export function decodeLegacyEdgePropNode(node) {
128
+ if (!isLegacyEdgePropNode(node)) {
129
+ throw new Error('Invalid legacy edge-property node: missing \\x01 prefix');
130
+ }
131
+ const parts = node.slice(1).split('\0');
132
+ if (parts.length !== 3) {
133
+ throw new Error(`Invalid legacy edge-property node: expected 3 segments, got ${parts.length}`);
134
+ }
135
+ const [from, to, label] = parts;
136
+ if (!from || !to || !label) {
137
+ throw new Error('Invalid legacy edge-property node: empty segment in decoded parts');
138
+ }
139
+ return { from, to, label };
140
+ }
141
+
94
142
  /**
95
143
  * Returns true if the encoded key is an edge property key.
96
144
  * @param {string} key - Encoded property key
@@ -24,8 +24,7 @@ const MAX_LOCAL_ID = 1 << 24;
24
24
 
25
25
  export default class LogicalBitmapIndexBuilder {
26
26
  /**
27
- * @param {Object} [options]
28
- * @param {import('../../ports/CodecPort.js').default} [options.codec]
27
+ * @param {{ codec?: import('../../ports/CodecPort.js').default }} [options]
29
28
  */
30
29
  constructor({ codec } = {}) {
31
30
  this._codec = codec || defaultCodec;
@@ -17,9 +17,7 @@ import { nodeVisibleV5, edgeVisibleV5 } from './StateSerializerV5.js';
17
17
 
18
18
  export default class LogicalIndexBuildService {
19
19
  /**
20
- * @param {Object} [options]
21
- * @param {import('../../ports/CodecPort.js').default} [options.codec]
22
- * @param {import('../../ports/LoggerPort.js').default} [options.logger]
20
+ * @param {{ codec?: import('../../ports/CodecPort.js').default, logger?: import('../../ports/LoggerPort.js').default }} [options]
23
21
  */
24
22
  constructor({ codec, logger } = {}) {
25
23
  this._codec = codec || defaultCodec;
@@ -30,9 +28,7 @@ export default class LogicalIndexBuildService {
30
28
  * Builds a complete logical index from materialized state.
31
29
  *
32
30
  * @param {import('./JoinReducer.js').WarpStateV5} state
33
- * @param {Object} [options]
34
- * @param {Record<string, { nodeToGlobal: Record<string, number>, nextLocalId: number }>} [options.existingMeta] - Prior meta shards for ID stability
35
- * @param {Record<string, number>|Array<[string, number]>} [options.existingLabels] - Prior label registry for append-only stability
31
+ * @param {{ existingMeta?: Record<string, { nodeToGlobal: Record<string, number>, nextLocalId: number }>, existingLabels?: Record<string, number>|Array<[string, number]> }} [options]
36
32
  * @returns {{ tree: Record<string, Uint8Array>, receipt: Record<string, unknown> }}
37
33
  */
38
34
  build(state, options = {}) {
@@ -89,8 +89,7 @@ function classifyShards(items) {
89
89
 
90
90
  export default class LogicalIndexReader {
91
91
  /**
92
- * @param {Object} [options]
93
- * @param {import('../../ports/CodecPort.js').default} [options.codec]
92
+ * @param {{ codec?: import('../../ports/CodecPort.js').default }} [options]
94
93
  */
95
94
  constructor({ codec } = {}) {
96
95
  this._codec = codec || defaultCodec;