@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
@@ -9,14 +9,16 @@
9
9
  * }
10
10
  */
11
11
 
12
- import { createORSet, orsetAdd, orsetRemove, orsetJoin, orsetContains } from '../crdt/ORSet.js';
12
+ import { createORSet, orsetAdd, orsetRemove, orsetJoin, orsetContains, orsetClone } from '../crdt/ORSet.js';
13
13
  import { createVersionVector, vvMerge, vvClone, vvDeserialize } from '../crdt/VersionVector.js';
14
14
  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';
21
+ import PatchError from '../errors/PatchError.js';
20
22
 
21
23
  // Re-export key codec functions for backward compatibility
22
24
  export {
@@ -26,13 +28,20 @@ export {
26
28
  encodeEdgePropKey, isEdgePropKey, decodeEdgePropKey,
27
29
  } from './KeyCodec.js';
28
30
 
31
+ // Re-export op normalization for consumers that operate on raw patches
32
+ export { normalizeRawOp, lowerCanonicalOp } from './OpNormalizer.js';
33
+
29
34
  /**
30
35
  * @typedef {Object} WarpStateV5
31
36
  * @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes
32
37
  * @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges
33
38
  * @property {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} prop - Properties with LWW
34
39
  * @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector
35
- * @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility)
40
+ * @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility).
41
+ * Always present at runtime (initialized to empty Map by createEmptyStateV5 and
42
+ * deserializeFullStateV5). Edge birth events were introduced in a later schema
43
+ * version; older checkpoints serialize without this field, but the deserializer
44
+ * always produces an empty Map for them.
36
45
  */
37
46
 
38
47
  /**
@@ -87,19 +96,173 @@ export function createEmptyStateV5() {
87
96
  * @returns {void}
88
97
  */
89
98
  /**
90
- * Known V2 operation types. Used for forward-compatibility validation.
99
+ * Known raw (wire-format) V2 operation types. These are the 6 types that
100
+ * appear in persisted patches and on the sync wire.
91
101
  * @type {ReadonlySet<string>}
92
102
  */
93
- const KNOWN_OPS = new Set(['NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue']);
103
+ export const RAW_KNOWN_OPS = new Set([
104
+ 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
105
+ 'PropSet', 'BlobValue',
106
+ ]);
107
+
108
+ /**
109
+ * Known canonical (internal) V2 operation types. Includes the 6 raw types
110
+ * plus the ADR 1 canonical split types `NodePropSet` and `EdgePropSet`.
111
+ * @type {ReadonlySet<string>}
112
+ */
113
+ export const CANONICAL_KNOWN_OPS = new Set([
114
+ 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove',
115
+ 'PropSet', 'NodePropSet', 'EdgePropSet', 'BlobValue',
116
+ ]);
117
+
118
+ /**
119
+ * Validates that an operation has a known raw (wire-format) type.
120
+ * Use this at sync/wire boundaries to fail-close on unknown or
121
+ * canonical-only types arriving over the wire.
122
+ *
123
+ * @param {{ type: string }} op
124
+ * @returns {boolean} True if the op type is in RAW_KNOWN_OPS
125
+ */
126
+ export function isKnownRawOp(op) {
127
+ return Boolean(op && typeof op.type === 'string' && RAW_KNOWN_OPS.has(op.type));
128
+ }
129
+
130
+ /**
131
+ * Validates that an operation has a known canonical (internal) type.
132
+ * Use this for internal guards after normalization.
133
+ *
134
+ * @param {{ type: string }} op
135
+ * @returns {boolean} True if the op type is in CANONICAL_KNOWN_OPS
136
+ */
137
+ export function isKnownCanonicalOp(op) {
138
+ return Boolean(op && typeof op.type === 'string' && CANONICAL_KNOWN_OPS.has(op.type));
139
+ }
94
140
 
95
141
  /**
96
142
  * Validates that an operation has a known type.
97
143
  *
144
+ * @deprecated Use {@link isKnownRawOp} for wire validation or
145
+ * {@link isKnownCanonicalOp} for internal guards.
98
146
  * @param {{ type: string }} op
99
- * @returns {boolean} True if the op type is in KNOWN_OPS
147
+ * @returns {boolean} True if the op type is a known raw wire type
100
148
  */
101
149
  export function isKnownOp(op) {
102
- return op && typeof op.type === 'string' && KNOWN_OPS.has(op.type);
150
+ return isKnownRawOp(op);
151
+ }
152
+
153
+ /**
154
+ * Asserts that `op[field]` is a string. Throws PatchError if not.
155
+ * @param {Record<string, unknown>} op
156
+ * @param {string} field
157
+ */
158
+ function requireString(op, field) {
159
+ if (typeof op[field] !== 'string') {
160
+ throw new PatchError(
161
+ `${op.type} op requires '${field}' to be a string, got ${typeof op[field]}`,
162
+ { context: { opType: op.type, field, actual: typeof op[field] } },
163
+ );
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Asserts that `op[field]` is iterable (Array, Set, or any Symbol.iterator).
169
+ * @param {Record<string, unknown>} op
170
+ * @param {string} field
171
+ */
172
+ function requireIterable(op, field) {
173
+ const val = op[field];
174
+ if (
175
+ val === null ||
176
+ val === undefined ||
177
+ typeof val !== 'object' ||
178
+ typeof /** @type {Iterable<unknown>} */ (val)[Symbol.iterator] !== 'function'
179
+ ) {
180
+ throw new PatchError(
181
+ `${op.type} op requires '${field}' to be iterable, got ${typeof val}`,
182
+ { context: { opType: op.type, field, actual: typeof val } },
183
+ );
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Asserts that `op.dot` is an object with writerId (string) and counter (number).
189
+ * @param {Record<string, unknown>} op
190
+ */
191
+ function requireDot(op) {
192
+ const { dot } = op;
193
+ if (!dot || typeof dot !== 'object') {
194
+ throw new PatchError(
195
+ `${op.type} op requires 'dot' to be an object, got ${typeof dot}`,
196
+ { context: { opType: op.type, field: 'dot', actual: typeof dot } },
197
+ );
198
+ }
199
+ const d = /** @type {Record<string, unknown>} */ (dot);
200
+ if (typeof d.writerId !== 'string') {
201
+ throw new PatchError(
202
+ `${op.type} op requires 'dot.writerId' to be a string, got ${typeof d.writerId}`,
203
+ { context: { opType: op.type, field: 'dot.writerId', actual: typeof d.writerId } },
204
+ );
205
+ }
206
+ if (typeof d.counter !== 'number') {
207
+ throw new PatchError(
208
+ `${op.type} op requires 'dot.counter' to be a number, got ${typeof d.counter}`,
209
+ { context: { opType: op.type, field: 'dot.counter', actual: typeof d.counter } },
210
+ );
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Validates that an operation has the required fields for its type.
216
+ * Throws PatchError for malformed ops. Unknown/BlobValue types pass through
217
+ * for forward compatibility.
218
+ *
219
+ * @param {Record<string, unknown>} op
220
+ */
221
+ function validateOp(op) {
222
+ if (!op || typeof op.type !== 'string') {
223
+ throw new PatchError(
224
+ `Invalid op: expected object with string 'type', got ${op === null || op === undefined ? String(op) : typeof op.type}`,
225
+ { context: { actual: op === null || op === undefined ? String(op) : typeof op.type } },
226
+ );
227
+ }
228
+
229
+ switch (op.type) {
230
+ case 'NodeAdd':
231
+ requireString(op, 'node');
232
+ requireDot(op);
233
+ break;
234
+ case 'NodeRemove':
235
+ // node is optional (informational for receipts); observedDots is required for mutation
236
+ requireIterable(op, 'observedDots');
237
+ break;
238
+ case 'EdgeAdd':
239
+ requireString(op, 'from');
240
+ requireString(op, 'to');
241
+ requireString(op, 'label');
242
+ requireDot(op);
243
+ break;
244
+ case 'EdgeRemove':
245
+ // from/to/label are optional (informational for receipts); observedDots is required for mutation
246
+ requireIterable(op, 'observedDots');
247
+ break;
248
+ case 'PropSet':
249
+ requireString(op, 'node');
250
+ requireString(op, 'key');
251
+ break;
252
+ case 'NodePropSet':
253
+ requireString(op, 'node');
254
+ requireString(op, 'key');
255
+ break;
256
+ case 'EdgePropSet':
257
+ requireString(op, 'from');
258
+ requireString(op, 'to');
259
+ requireString(op, 'label');
260
+ requireString(op, 'key');
261
+ break;
262
+ default:
263
+ // BlobValue and unknown types: no validation (forward-compat)
264
+ break;
265
+ }
103
266
  }
104
267
 
105
268
  /**
@@ -110,6 +273,7 @@ export function isKnownOp(op) {
110
273
  * @param {import('../utils/EventId.js').EventId} eventId - The event ID for LWW ordering
111
274
  */
112
275
  export function applyOpV2(state, op, eventId) {
276
+ validateOp(/** @type {Record<string, unknown>} */ (op));
113
277
  switch (op.type) {
114
278
  case 'NodeAdd':
115
279
  orsetAdd(state.nodeAlive, /** @type {string} */ (op.node), /** @type {import('../crdt/Dot.js').Dot} */ (op.dot));
@@ -134,8 +298,34 @@ export function applyOpV2(state, op, eventId) {
134
298
  case 'EdgeRemove':
135
299
  orsetRemove(state.edgeAlive, /** @type {Set<string>} */ (/** @type {unknown} */ (op.observedDots)));
136
300
  break;
301
+ case 'NodePropSet': {
302
+ const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
303
+ const current = state.prop.get(key);
304
+ state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
305
+ break;
306
+ }
307
+ case 'EdgePropSet': {
308
+ const key = encodeEdgePropKey(
309
+ /** @type {string} */ (op.from),
310
+ /** @type {string} */ (op.to),
311
+ /** @type {string} */ (op.label),
312
+ /** @type {string} */ (op.key),
313
+ );
314
+ const current = state.prop.get(key);
315
+ state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
316
+ break;
317
+ }
137
318
  case 'PropSet': {
138
- // Uses EventId-based LWW, same as v4
319
+ // Legacy raw PropSet — must NOT carry edge-property encoding at this point.
320
+ // If it does, normalization was skipped.
321
+ if (typeof op.node === 'string' && op.node[0] === EDGE_PROP_PREFIX) {
322
+ throw new PatchError(
323
+ 'Unnormalized legacy edge-property PropSet reached canonical apply path. ' +
324
+ 'Call normalizeRawOp() at the decode boundary.',
325
+ { context: { opType: 'PropSet', node: op.node } },
326
+ );
327
+ }
328
+ // Plain node property (backward-compat for callers that bypass normalization)
139
329
  const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key));
140
330
  const current = state.prop.get(key);
141
331
  state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<unknown>} */ (lwwMax(current, lwwSet(eventId, op.value))));
@@ -167,6 +357,8 @@ const RECEIPT_OP_TYPE = {
167
357
  EdgeAdd: 'EdgeAdd',
168
358
  EdgeRemove: 'EdgeTombstone',
169
359
  PropSet: 'PropSet',
360
+ NodePropSet: 'NodePropSet',
361
+ EdgePropSet: 'EdgePropSet',
170
362
  BlobValue: 'BlobValue',
171
363
  };
172
364
 
@@ -213,21 +405,18 @@ function nodeAddOutcome(orset, op) {
213
405
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID (or '*') as target
214
406
  */
215
407
  function nodeRemoveOutcome(orset, op) {
216
- // Check if any of the observed dots are currently non-tombstoned
408
+ // Build a reverse index (dot → elementId) for the observed dots to avoid
409
+ // O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
410
+ const targetDots = op.observedDots instanceof Set
411
+ ? op.observedDots
412
+ : new Set(op.observedDots);
413
+ const dotToElement = buildDotToElement(orset, targetDots);
414
+
217
415
  let effective = false;
218
- for (const encodedDot of op.observedDots) {
219
- if (!orset.tombstones.has(encodedDot)) {
220
- // This dot exists and is not yet tombstoned, so the remove is effective
221
- // Check if any entry actually has this dot
222
- for (const dots of orset.entries.values()) {
223
- if (dots.has(encodedDot)) {
224
- effective = true;
225
- break;
226
- }
227
- }
228
- if (effective) {
229
- break;
230
- }
416
+ for (const encodedDot of targetDots) {
417
+ if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
418
+ effective = true;
419
+ break;
231
420
  }
232
421
  }
233
422
  const target = op.node || '*';
@@ -279,18 +468,18 @@ function edgeAddOutcome(orset, op, edgeKey) {
279
468
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key (or '*') as target
280
469
  */
281
470
  function edgeRemoveOutcome(orset, op) {
471
+ // Build a reverse index (dot → elementId) for the observed dots to avoid
472
+ // O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
473
+ const targetDots = op.observedDots instanceof Set
474
+ ? op.observedDots
475
+ : new Set(op.observedDots);
476
+ const dotToElement = buildDotToElement(orset, targetDots);
477
+
282
478
  let effective = false;
283
- for (const encodedDot of op.observedDots) {
284
- if (!orset.tombstones.has(encodedDot)) {
285
- for (const dots of orset.entries.values()) {
286
- if (dots.has(encodedDot)) {
287
- effective = true;
288
- break;
289
- }
290
- }
291
- if (effective) {
292
- break;
293
- }
479
+ for (const encodedDot of targetDots) {
480
+ if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
481
+ effective = true;
482
+ break;
294
483
  }
295
484
  }
296
485
  // Construct target from op fields if available
@@ -301,7 +490,7 @@ function edgeRemoveOutcome(orset, op) {
301
490
  }
302
491
 
303
492
  /**
304
- * Determines the receipt outcome for a PropSet operation.
493
+ * Determines the receipt outcome for a property operation given a pre-computed key.
305
494
  *
306
495
  * Uses LWW (Last-Write-Wins) semantics to determine whether the incoming property
307
496
  * value wins over any existing value. The comparison is based on EventId ordering:
@@ -315,41 +504,61 @@ function edgeRemoveOutcome(orset, op) {
315
504
  * - `redundant`: Exact same write (identical EventId)
316
505
  *
317
506
  * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap - The properties map keyed by encoded prop keys
318
- * @param {Object} op - The PropSet operation
319
- * @param {string} op.node - Node ID owning the property
320
- * @param {string} op.key - Property key/name
321
- * @param {unknown} op.value - Property value to set
507
+ * @param {string} key - Pre-encoded property key (node or edge)
322
508
  * @param {import('../utils/EventId.js').EventId} eventId - The event ID for this operation, used for LWW comparison
323
509
  * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
324
510
  * Outcome with encoded prop key as target; includes reason when superseded
325
511
  */
326
- function propSetOutcome(propMap, op, eventId) {
327
- const key = encodePropKey(op.node, op.key);
512
+ function propOutcomeForKey(propMap, key, eventId) {
328
513
  const current = propMap.get(key);
329
- const target = key;
330
514
 
331
515
  if (!current) {
332
- // No existing value -- this write wins
333
- return { target, result: 'applied' };
516
+ return { target: key, result: 'applied' };
334
517
  }
335
518
 
336
- // Compare the incoming EventId with the existing register's EventId
337
519
  const cmp = compareEventIds(eventId, current.eventId);
338
520
  if (cmp > 0) {
339
- // Incoming write wins
340
- return { target, result: 'applied' };
521
+ return { target: key, result: 'applied' };
341
522
  }
342
523
  if (cmp < 0) {
343
- // Existing write wins
344
524
  const winner = current.eventId;
345
525
  return {
346
- target,
526
+ target: key,
347
527
  result: 'superseded',
348
528
  reason: `LWW: writer ${winner.writerId} at lamport ${winner.lamport} wins`,
349
529
  };
350
530
  }
351
- // Same EventId -- redundant (exact same write)
352
- return { target, result: 'redundant' };
531
+ return { target: key, result: 'redundant' };
532
+ }
533
+
534
+ /**
535
+ * Determines the receipt outcome for a PropSet/NodePropSet operation.
536
+ *
537
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
538
+ * @param {Object} op
539
+ * @param {string} op.node - Node ID owning the property
540
+ * @param {string} op.key - Property key/name
541
+ * @param {import('../utils/EventId.js').EventId} eventId
542
+ * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
543
+ */
544
+ function propSetOutcome(propMap, op, eventId) {
545
+ return propOutcomeForKey(propMap, encodePropKey(op.node, op.key), eventId);
546
+ }
547
+
548
+ /**
549
+ * Determines the receipt outcome for an EdgePropSet operation.
550
+ *
551
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} propMap
552
+ * @param {Object} op
553
+ * @param {string} op.from
554
+ * @param {string} op.to
555
+ * @param {string} op.label
556
+ * @param {string} op.key
557
+ * @param {import('../utils/EventId.js').EventId} eventId
558
+ * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
559
+ */
560
+ function edgePropSetOutcome(propMap, op, eventId) {
561
+ return propOutcomeForKey(propMap, encodeEdgePropKey(op.from, op.to, op.label, op.key), eventId);
353
562
  }
354
563
 
355
564
  /**
@@ -396,7 +605,7 @@ function updateFrontierFromPatch(state, patch) {
396
605
  export function applyFast(state, patch, patchSha) {
397
606
  for (let i = 0; i < patch.ops.length; i++) {
398
607
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
399
- applyOpV2(state, patch.ops[i], eventId);
608
+ applyOpV2(state, normalizeRawOp(patch.ops[i]), eventId);
400
609
  }
401
610
  updateFrontierFromPatch(state, patch);
402
611
  return state;
@@ -491,11 +700,17 @@ function snapshotBeforeOp(state, op) {
491
700
  const aliveBeforeEdges = aliveElementsForDots(state.edgeAlive, edgeDots);
492
701
  return { aliveBeforeEdges };
493
702
  }
494
- case 'PropSet': {
703
+ case 'PropSet':
704
+ case 'NodePropSet': {
495
705
  const pk = encodePropKey(op.node, op.key);
496
706
  const reg = state.prop.get(pk);
497
707
  return { prevPropValue: reg ? reg.value : undefined, propKey: pk };
498
708
  }
709
+ case 'EdgePropSet': {
710
+ const epk = encodeEdgePropKey(op.from, op.to, op.label, op.key);
711
+ const ereg = state.prop.get(epk);
712
+ return { prevPropValue: ereg ? ereg.value : undefined, propKey: epk };
713
+ }
499
714
  default:
500
715
  return {};
501
716
  }
@@ -532,7 +747,8 @@ function accumulateOpDiff(diff, state, op, before) {
532
747
  collectEdgeRemovals(diff, state, before);
533
748
  break;
534
749
  }
535
- case 'PropSet': {
750
+ case 'PropSet':
751
+ case 'NodePropSet': {
536
752
  const reg = state.prop.get(/** @type {string} */ (before.propKey));
537
753
  const newVal = reg ? reg.value : undefined;
538
754
  if (newVal !== before.prevPropValue) {
@@ -545,6 +761,19 @@ function accumulateOpDiff(diff, state, op, before) {
545
761
  }
546
762
  break;
547
763
  }
764
+ case 'EdgePropSet': {
765
+ const ereg = state.prop.get(/** @type {string} */ (before.propKey));
766
+ const eNewVal = ereg ? ereg.value : undefined;
767
+ if (eNewVal !== before.prevPropValue) {
768
+ diff.propsChanged.push({
769
+ nodeId: encodeEdgeKey(op.from, op.to, op.label),
770
+ key: op.key,
771
+ value: eNewVal,
772
+ prevValue: before.prevPropValue,
773
+ });
774
+ }
775
+ break;
776
+ }
548
777
  default:
549
778
  break;
550
779
  }
@@ -593,7 +822,7 @@ function collectEdgeRemovals(diff, state, before) {
593
822
  * @param {Object} patch - The patch to apply
594
823
  * @param {string} patch.writer
595
824
  * @param {number} patch.lamport
596
- * @param {Array<Object>} patch.ops
825
+ * @param {Array<import('../types/WarpTypesV2.js').OpV2 | {type: string}>} patch.ops
597
826
  * @param {Map<string, number>|{[x: string]: number}} patch.context
598
827
  * @param {string} patchSha - Git SHA of the patch commit
599
828
  * @returns {{state: WarpStateV5, diff: import('../types/PatchDiff.js').PatchDiff}}
@@ -602,12 +831,12 @@ export function applyWithDiff(state, patch, patchSha) {
602
831
  const diff = createEmptyDiff();
603
832
 
604
833
  for (let i = 0; i < patch.ops.length; i++) {
605
- const op = patch.ops[i];
834
+ const canonOp = /** @type {import('../types/WarpTypesV2.js').CanonicalOpV2} */ (normalizeRawOp(patch.ops[i]));
835
+ validateOp(/** @type {Record<string, unknown>} */ (canonOp));
606
836
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
607
- const typedOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (op);
608
- const before = snapshotBeforeOp(state, typedOp);
609
- applyOpV2(state, typedOp, eventId);
610
- accumulateOpDiff(diff, state, typedOp, before);
837
+ const before = snapshotBeforeOp(state, canonOp);
838
+ applyOpV2(state, canonOp, eventId);
839
+ accumulateOpDiff(diff, state, canonOp, before);
611
840
  }
612
841
 
613
842
  updateFrontierFromPatch(state, patch);
@@ -630,40 +859,47 @@ export function applyWithReceipt(state, patch, patchSha) {
630
859
  /** @type {import('../types/TickReceipt.js').OpOutcome[]} */
631
860
  const opResults = [];
632
861
  for (let i = 0; i < patch.ops.length; i++) {
633
- const op = patch.ops[i];
862
+ const canonOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (normalizeRawOp(patch.ops[i]));
863
+ validateOp(/** @type {Record<string, unknown>} */ (canonOp));
634
864
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
635
865
 
636
866
  // Determine outcome BEFORE applying the op (state is pre-op)
637
867
  /** @type {{target: string, result: string, reason?: string}} */
638
868
  let outcome;
639
- switch (op.type) {
869
+ switch (canonOp.type) {
640
870
  case 'NodeAdd':
641
- outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (op));
871
+ outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp));
642
872
  break;
643
873
  case 'NodeRemove':
644
- outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (op));
874
+ outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (canonOp));
645
875
  break;
646
876
  case 'EdgeAdd': {
647
- const edgeKey = encodeEdgeKey(/** @type {string} */ (op.from), /** @type {string} */ (op.to), /** @type {string} */ (op.label));
648
- outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (op), edgeKey);
877
+ const edgeKey = encodeEdgeKey(canonOp.from, canonOp.to, canonOp.label);
878
+ outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (canonOp), edgeKey);
649
879
  break;
650
880
  }
651
881
  case 'EdgeRemove':
652
- outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (op));
882
+ outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (canonOp));
653
883
  break;
654
884
  case 'PropSet':
655
- outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (op), eventId);
885
+ case 'NodePropSet':
886
+ outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (canonOp), eventId);
887
+ break;
888
+ case 'EdgePropSet':
889
+ outcome = edgePropSetOutcome(state.prop, /** @type {{from: string, to: string, label: string, key: string, value: *}} */ (canonOp), eventId);
656
890
  break;
657
- default:
891
+ default: {
658
892
  // Unknown or BlobValue — always applied
659
- outcome = { target: op.node || op.oid || '*', result: 'applied' };
893
+ const anyOp = /** @type {Record<string, string>} */ (canonOp);
894
+ outcome = { target: anyOp.node || anyOp.oid || '*', result: 'applied' };
660
895
  break;
896
+ }
661
897
  }
662
898
 
663
899
  // Apply the op (mutates state)
664
- applyOpV2(state, op, eventId);
900
+ applyOpV2(state, canonOp, eventId);
665
901
 
666
- const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[op.type] || op.type;
902
+ const receiptOp = /** @type {Record<string, string>} */ (RECEIPT_OP_TYPE)[canonOp.type] || canonOp.type;
667
903
  // Skip unknown/forward-compatible op types that aren't valid receipt ops
668
904
  if (!VALID_RECEIPT_OPS.has(receiptOp)) {
669
905
  continue;
@@ -868,16 +1104,16 @@ export function reduceV5(patches, initialState, options) {
868
1104
  * - Creating a branch point for speculative execution
869
1105
  * - Ensuring immutability when passing state across API boundaries
870
1106
  *
871
- * **Implementation Note**: OR-Sets are cloned by joining with an empty set,
872
- * which creates new data structures with identical contents.
1107
+ * **Implementation Note**: OR-Sets are cloned via `orsetClone()` which
1108
+ * directly copies entries and tombstones without merge logic overhead.
873
1109
  *
874
1110
  * @param {WarpStateV5} state - The state to clone
875
1111
  * @returns {WarpStateV5} A new state with identical contents but independent data structures
876
1112
  */
877
1113
  export function cloneStateV5(state) {
878
1114
  return {
879
- nodeAlive: orsetJoin(state.nodeAlive, createORSet()),
880
- edgeAlive: orsetJoin(state.edgeAlive, createORSet()),
1115
+ nodeAlive: orsetClone(state.nodeAlive),
1116
+ edgeAlive: orsetClone(state.edgeAlive),
881
1117
  prop: new Map(state.prop),
882
1118
  observedFrontier: vvClone(state.observedFrontier),
883
1119
  edgeBirthEvent: new Map(state.edgeBirthEvent || []),
@@ -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