@git-stunts/git-warp 12.2.0 → 12.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +8 -6
  2. package/bin/cli/commands/trust.js +37 -1
  3. package/bin/cli/infrastructure.js +14 -1
  4. package/bin/cli/schemas.js +4 -4
  5. package/bin/warp-graph.js +4 -1
  6. package/index.d.ts +17 -1
  7. package/package.json +1 -1
  8. package/src/domain/WarpGraph.js +1 -1
  9. package/src/domain/crdt/Dot.js +5 -0
  10. package/src/domain/crdt/LWW.js +3 -1
  11. package/src/domain/crdt/ORSet.js +30 -23
  12. package/src/domain/crdt/VersionVector.js +12 -0
  13. package/src/domain/errors/PatchError.js +27 -0
  14. package/src/domain/errors/StorageError.js +8 -0
  15. package/src/domain/errors/WriterError.js +5 -0
  16. package/src/domain/errors/index.js +1 -0
  17. package/src/domain/services/AuditVerifierService.js +32 -2
  18. package/src/domain/services/BitmapIndexBuilder.js +14 -9
  19. package/src/domain/services/CheckpointService.js +10 -1
  20. package/src/domain/services/GCPolicy.js +25 -4
  21. package/src/domain/services/GraphTraversal.js +3 -1
  22. package/src/domain/services/IncrementalIndexUpdater.js +179 -36
  23. package/src/domain/services/JoinReducer.js +141 -31
  24. package/src/domain/services/MaterializedViewService.js +13 -2
  25. package/src/domain/services/PatchBuilderV2.js +181 -142
  26. package/src/domain/services/QueryBuilder.js +4 -0
  27. package/src/domain/services/SyncController.js +12 -31
  28. package/src/domain/services/SyncProtocol.js +75 -32
  29. package/src/domain/trust/TrustRecordService.js +50 -36
  30. package/src/domain/utils/CachedValue.js +34 -5
  31. package/src/domain/utils/EventId.js +4 -1
  32. package/src/domain/utils/LRUCache.js +3 -1
  33. package/src/domain/utils/RefLayout.js +4 -0
  34. package/src/domain/utils/canonicalStringify.js +48 -18
  35. package/src/domain/utils/matchGlob.js +7 -0
  36. package/src/domain/warp/PatchSession.js +30 -24
  37. package/src/domain/warp/Writer.js +5 -0
  38. package/src/domain/warp/_wiredMethods.d.ts +1 -1
  39. package/src/domain/warp/checkpoint.methods.js +36 -7
  40. package/src/domain/warp/materialize.methods.js +44 -5
  41. package/src/domain/warp/materializeAdvanced.methods.js +50 -10
  42. package/src/domain/warp/patch.methods.js +19 -11
  43. package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
  44. package/src/infrastructure/codecs/CborCodec.js +2 -0
  45. package/src/domain/utils/fnv1a.js +0 -20
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Stateless service that computes dirty shard buffers from a PatchDiff.
2
+ * Stateful service that computes dirty shard buffers from a PatchDiff.
3
3
  *
4
4
  * Given a diff of alive-ness transitions + a shard loader for the existing
5
5
  * index tree, produces only the shard buffers that changed. The caller
6
6
  * merges them back into the tree via `{ ...existingTree, ...dirtyShards }`.
7
7
  *
8
+ * Instance state:
9
+ * - `_edgeAdjacencyCache` stores a WeakMap keyed by `state.edgeAlive` ORSet
10
+ * identity, mapping nodeId -> incident alive edge keys.
11
+ * - Cache lifetime is tied to the updater instance and is reconciled per diff
12
+ * once initialized. Reuse one instance for a single linear state stream;
13
+ * create a new instance to reset cache state across independent streams.
14
+ *
8
15
  * @module domain/services/IncrementalIndexUpdater
9
16
  */
10
17
 
@@ -40,6 +47,15 @@ export default class IncrementalIndexUpdater {
40
47
  */
41
48
  constructor({ codec } = {}) {
42
49
  this._codec = codec || defaultCodec;
50
+ /** @type {WeakMap<import('../crdt/ORSet.js').ORSet, Map<string, Set<string>>>} */
51
+ this._edgeAdjacencyCache = new WeakMap();
52
+ /**
53
+ * Cached next label ID — avoids O(L) max-scan per new label.
54
+ * Initialized lazily from existing labels on first _ensureLabel call.
55
+ * @type {number|null}
56
+ * @private
57
+ */
58
+ this._nextLabelId = null;
43
59
  }
44
60
 
45
61
  /**
@@ -63,8 +79,22 @@ export default class IncrementalIndexUpdater {
63
79
  const out = {};
64
80
 
65
81
  const labels = this._loadLabels(loadShard);
82
+ // Reset cached next label ID so _ensureLabel re-scans the fresh labels
83
+ // object loaded above. Without this, a stale _nextLabelId from a prior
84
+ // applyDiff call could collide with IDs already present in the new labels.
85
+ this._nextLabelId = null;
66
86
  let labelsDirty = false;
67
87
 
88
+ // Determine which added nodes are true re-adds (already have global IDs).
89
+ // Brand-new nodes cannot have pre-existing indexed edges to restore.
90
+ const readdedNodes = new Set();
91
+ for (const nodeId of diff.nodesAdded) {
92
+ const meta = this._getOrLoadMeta(computeShardKey(nodeId), metaCache, loadShard);
93
+ if (this._findGlobalId(meta, nodeId) !== undefined) {
94
+ readdedNodes.add(nodeId);
95
+ }
96
+ }
97
+
68
98
  for (const nodeId of diff.nodesAdded) {
69
99
  this._handleNodeAdd(nodeId, metaCache, loadShard);
70
100
  }
@@ -96,25 +126,22 @@ export default class IncrementalIndexUpdater {
96
126
  this._handleEdgeRemove(edge, labels, metaCache, fwdCache, revCache, loadShard);
97
127
  }
98
128
 
99
- // Restore edges for re-added nodes. When a node transitions
100
- // not-alive -> alive, edges touching it that are alive in the ORSet
101
- // become visible again. The diff only tracks explicit EdgeAdd ops,
102
- // not these implicit visibility transitions.
103
- //
104
- // Known O(E) worst-case: scans all alive edges. For genuinely new nodes
105
- // (not re-adds), this scan is unnecessary since they can't have pre-existing
106
- // edges. _findGlobalId returns undefined for new nodes, so this could be
107
- // short-circuited deferred for a future optimization pass.
108
- if (diff.nodesAdded.length > 0) {
109
- const addedSet = new Set(diff.nodesAdded);
129
+ // Keep adjacency cache in sync for every diff once initialized, so later
130
+ // re-add restores never consult stale edge membership.
131
+ let readdAdjacency = null;
132
+ if (readdedNodes.size > 0 || this._edgeAdjacencyCache.has(state.edgeAlive)) {
133
+ readdAdjacency = this._getOrBuildAliveEdgeAdjacency(state, diff);
134
+ }
135
+
136
+ // Restore edges for re-added nodes only. When a node transitions
137
+ // not-alive -> alive, alive OR-Set edges touching it become visible again.
138
+ // Brand-new nodes are skipped because they have no prior global ID.
139
+ if (readdedNodes.size > 0 && readdAdjacency) {
110
140
  const diffEdgeSet = new Set(
111
141
  diff.edgesAdded.map((e) => `${e.from}\0${e.to}\0${e.label}`),
112
142
  );
113
- for (const edgeKey of orsetElements(state.edgeAlive)) {
143
+ for (const edgeKey of this._collectReaddedEdgeKeys(readdAdjacency, readdedNodes)) {
114
144
  const { from, to, label } = decodeEdgeKey(edgeKey);
115
- if (!addedSet.has(from) && !addedSet.has(to)) {
116
- continue;
117
- }
118
145
  if (!orsetContains(state.nodeAlive, from) || !orsetContains(state.nodeAlive, to)) {
119
146
  continue;
120
147
  }
@@ -255,15 +282,16 @@ export default class IncrementalIndexUpdater {
255
282
  for (const bucket of Object.keys(fwdData)) {
256
283
  const gidStr = String(deadGid);
257
284
  if (fwdData[bucket] && fwdData[bucket][gidStr]) {
258
- // Before clearing, find the targets so we can clean reverse bitmaps
259
- const targets = RoaringBitmap32.deserialize(
285
+ // Deserialize once, collect targets, then clear+serialize in place.
286
+ const bm = RoaringBitmap32.deserialize(
260
287
  toBytes(fwdData[bucket][gidStr]),
261
288
  true,
262
- ).toArray();
289
+ );
290
+ const targets = bm.toArray();
263
291
 
264
292
  // Clear this node's outgoing bitmap
265
- const empty = new RoaringBitmap32();
266
- fwdData[bucket][gidStr] = empty.serialize(true);
293
+ bm.clear();
294
+ fwdData[bucket][gidStr] = bm.serialize(true);
267
295
 
268
296
  // Remove deadGid from each target's reverse bitmap
269
297
  for (const targetGid of targets) {
@@ -273,12 +301,12 @@ export default class IncrementalIndexUpdater {
273
301
  const revData = this._getOrLoadEdgeShard(revCache, 'rev', targetShard, loadShard);
274
302
  const targetGidStr = String(targetGid);
275
303
  if (revData[bucket] && revData[bucket][targetGidStr]) {
276
- const bm = RoaringBitmap32.deserialize(
304
+ const targetBm = RoaringBitmap32.deserialize(
277
305
  toBytes(revData[bucket][targetGidStr]),
278
306
  true,
279
307
  );
280
- bm.remove(deadGid);
281
- revData[bucket][targetGidStr] = bm.serialize(true);
308
+ targetBm.remove(deadGid);
309
+ revData[bucket][targetGidStr] = targetBm.serialize(true);
282
310
  }
283
311
  }
284
312
  }
@@ -290,13 +318,14 @@ export default class IncrementalIndexUpdater {
290
318
  for (const bucket of Object.keys(revData)) {
291
319
  const gidStr = String(deadGid);
292
320
  if (revData[bucket] && revData[bucket][gidStr]) {
293
- const sources = RoaringBitmap32.deserialize(
321
+ const bm = RoaringBitmap32.deserialize(
294
322
  toBytes(revData[bucket][gidStr]),
295
323
  true,
296
- ).toArray();
324
+ );
325
+ const sources = bm.toArray();
297
326
 
298
- const empty = new RoaringBitmap32();
299
- revData[bucket][gidStr] = empty.serialize(true);
327
+ bm.clear();
328
+ revData[bucket][gidStr] = bm.serialize(true);
300
329
 
301
330
  // Remove deadGid from each source's forward bitmap
302
331
  for (const sourceGid of sources) {
@@ -306,12 +335,12 @@ export default class IncrementalIndexUpdater {
306
335
  const fwdDataPeer = this._getOrLoadEdgeShard(fwdCache, 'fwd', sourceShard, loadShard);
307
336
  const sourceGidStr = String(sourceGid);
308
337
  if (fwdDataPeer[bucket] && fwdDataPeer[bucket][sourceGidStr]) {
309
- const bm = RoaringBitmap32.deserialize(
338
+ const sourceBm = RoaringBitmap32.deserialize(
310
339
  toBytes(fwdDataPeer[bucket][sourceGidStr]),
311
340
  true,
312
341
  );
313
- bm.remove(deadGid);
314
- fwdDataPeer[bucket][sourceGidStr] = bm.serialize(true);
342
+ sourceBm.remove(deadGid);
343
+ fwdDataPeer[bucket][sourceGidStr] = sourceBm.serialize(true);
315
344
  }
316
345
  }
317
346
  }
@@ -350,13 +379,19 @@ export default class IncrementalIndexUpdater {
350
379
  if (Object.prototype.hasOwnProperty.call(labels, label)) {
351
380
  return false;
352
381
  }
353
- let maxId = -1;
354
- for (const id of Object.values(labels)) {
355
- if (id > maxId) {
356
- maxId = id;
382
+ // Lazily initialize _nextLabelId from existing labels (O(L) once),
383
+ // then O(1) per subsequent new label.
384
+ if (this._nextLabelId === null) {
385
+ let maxId = -1;
386
+ for (const id of Object.values(labels)) {
387
+ if (id > maxId) {
388
+ maxId = id;
389
+ }
357
390
  }
391
+ this._nextLabelId = maxId + 1;
358
392
  }
359
- labels[label] = maxId + 1;
393
+ labels[label] = this._nextLabelId;
394
+ this._nextLabelId++;
360
395
  return true;
361
396
  }
362
397
 
@@ -743,6 +778,111 @@ export default class IncrementalIndexUpdater {
743
778
  return meta.nodeToGlobalMap.get(nodeId);
744
779
  }
745
780
 
781
+ /**
782
+ * Collects alive edge keys incident to re-added nodes.
783
+ *
784
+ * Uses an ORSet-keyed adjacency cache so repeated updates can enumerate
785
+ * candidates by degree rather than scanning all alive edges each time.
786
+ *
787
+ * @param {Map<string, Set<string>>} adjacency
788
+ * @param {Set<string>} readdedNodes
789
+ * @returns {Set<string>}
790
+ * @private
791
+ */
792
+ _collectReaddedEdgeKeys(adjacency, readdedNodes) {
793
+ const keys = new Set();
794
+ for (const nodeId of readdedNodes) {
795
+ const incident = adjacency.get(nodeId);
796
+ if (!incident) {
797
+ continue;
798
+ }
799
+ for (const edgeKey of incident) {
800
+ keys.add(edgeKey);
801
+ }
802
+ }
803
+ return keys;
804
+ }
805
+
806
+ /**
807
+ * Gets or builds a node -> alive edgeKey adjacency map for state.edgeAlive.
808
+ *
809
+ * For cached maps, applies diff edge transitions to keep membership current.
810
+ *
811
+ * @param {import('./JoinReducer.js').WarpStateV5} state
812
+ * @param {import('../types/PatchDiff.js').PatchDiff} diff
813
+ * @returns {Map<string, Set<string>>}
814
+ * @private
815
+ */
816
+ _getOrBuildAliveEdgeAdjacency(state, diff) {
817
+ const { edgeAlive } = state;
818
+ let adjacency = this._edgeAdjacencyCache.get(edgeAlive);
819
+ if (!adjacency) {
820
+ adjacency = new Map();
821
+ for (const edgeKey of orsetElements(edgeAlive)) {
822
+ const { from, to } = decodeEdgeKey(edgeKey);
823
+ this._addEdgeKeyToAdjacency(adjacency, from, edgeKey);
824
+ this._addEdgeKeyToAdjacency(adjacency, to, edgeKey);
825
+ }
826
+ this._edgeAdjacencyCache.set(edgeAlive, adjacency);
827
+ return adjacency;
828
+ }
829
+
830
+ for (const edge of diff.edgesAdded) {
831
+ const edgeKey = `${edge.from}\0${edge.to}\0${edge.label}`;
832
+ if (!orsetContains(edgeAlive, edgeKey)) {
833
+ continue;
834
+ }
835
+ this._addEdgeKeyToAdjacency(adjacency, edge.from, edgeKey);
836
+ this._addEdgeKeyToAdjacency(adjacency, edge.to, edgeKey);
837
+ }
838
+ for (const edge of diff.edgesRemoved) {
839
+ const edgeKey = `${edge.from}\0${edge.to}\0${edge.label}`;
840
+ if (orsetContains(edgeAlive, edgeKey)) {
841
+ continue;
842
+ }
843
+ this._removeEdgeKeyFromAdjacency(adjacency, edge.from, edgeKey);
844
+ this._removeEdgeKeyFromAdjacency(adjacency, edge.to, edgeKey);
845
+ }
846
+
847
+ return adjacency;
848
+ }
849
+
850
+ /**
851
+ * Adds an edge key to one endpoint's adjacency set.
852
+ *
853
+ * @param {Map<string, Set<string>>} adjacency
854
+ * @param {string} nodeId
855
+ * @param {string} edgeKey
856
+ * @private
857
+ */
858
+ _addEdgeKeyToAdjacency(adjacency, nodeId, edgeKey) {
859
+ let set = adjacency.get(nodeId);
860
+ if (!set) {
861
+ set = new Set();
862
+ adjacency.set(nodeId, set);
863
+ }
864
+ set.add(edgeKey);
865
+ }
866
+
867
+ /**
868
+ * Removes an edge key from one endpoint's adjacency set.
869
+ *
870
+ * @param {Map<string, Set<string>>} adjacency
871
+ * @param {string} nodeId
872
+ * @param {string} edgeKey
873
+ * @private
874
+ */
875
+ _removeEdgeKeyFromAdjacency(adjacency, nodeId, edgeKey) {
876
+ const set = adjacency.get(nodeId);
877
+ if (!set) {
878
+ return;
879
+ }
880
+ set.delete(edgeKey);
881
+ if (set.size === 0) {
882
+ adjacency.delete(nodeId);
883
+ }
884
+ }
885
+
746
886
  /**
747
887
  * Deserializes a bitmap from edge shard data, or creates a new one.
748
888
  *
@@ -753,6 +893,9 @@ export default class IncrementalIndexUpdater {
753
893
  * @private
754
894
  */
755
895
  _deserializeBitmap(data, bucket, ownerStr) {
896
+ // getRoaringBitmap32() is internally memoized (returns cached constructor
897
+ // after first resolution). The repeated calls are cheap but the pattern
898
+ // is noisy. A future cleanup could cache the constructor at instance level.
756
899
  const RoaringBitmap32 = getRoaringBitmap32();
757
900
  if (data[bucket] && data[bucket][ownerStr]) {
758
901
  return RoaringBitmap32.deserialize(
@@ -9,7 +9,7 @@
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';
@@ -17,6 +17,7 @@ import { createTickReceipt, OP_TYPES } from '../types/TickReceipt.js';
17
17
  import { encodeDot } from '../crdt/Dot.js';
18
18
  import { encodeEdgeKey, decodeEdgeKey, encodePropKey } from './KeyCodec.js';
19
19
  import { createEmptyDiff, mergeDiffs } from '../types/PatchDiff.js';
20
+ import PatchError from '../errors/PatchError.js';
20
21
 
21
22
  // Re-export key codec functions for backward compatibility
22
23
  export {
@@ -32,7 +33,11 @@ export {
32
33
  * @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges
33
34
  * @property {Map<string, import('../crdt/LWW.js').LWWRegister<unknown>>} prop - Properties with LWW
34
35
  * @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)
36
+ * @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility).
37
+ * Always present at runtime (initialized to empty Map by createEmptyStateV5 and
38
+ * deserializeFullStateV5). Edge birth events were introduced in a later schema
39
+ * version; older checkpoints serialize without this field, but the deserializer
40
+ * always produces an empty Map for them.
36
41
  */
37
42
 
38
43
  /**
@@ -102,6 +107,111 @@ export function isKnownOp(op) {
102
107
  return op && typeof op.type === 'string' && KNOWN_OPS.has(op.type);
103
108
  }
104
109
 
110
+ /**
111
+ * Asserts that `op[field]` is a string. Throws PatchError if not.
112
+ * @param {Record<string, unknown>} op
113
+ * @param {string} field
114
+ */
115
+ function requireString(op, field) {
116
+ if (typeof op[field] !== 'string') {
117
+ throw new PatchError(
118
+ `${op.type} op requires '${field}' to be a string, got ${typeof op[field]}`,
119
+ { context: { opType: op.type, field, actual: typeof op[field] } },
120
+ );
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Asserts that `op[field]` is iterable (Array, Set, or any Symbol.iterator).
126
+ * @param {Record<string, unknown>} op
127
+ * @param {string} field
128
+ */
129
+ function requireIterable(op, field) {
130
+ const val = op[field];
131
+ if (
132
+ val === null ||
133
+ val === undefined ||
134
+ typeof val !== 'object' ||
135
+ typeof /** @type {Iterable<unknown>} */ (val)[Symbol.iterator] !== 'function'
136
+ ) {
137
+ throw new PatchError(
138
+ `${op.type} op requires '${field}' to be iterable, got ${typeof val}`,
139
+ { context: { opType: op.type, field, actual: typeof val } },
140
+ );
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Asserts that `op.dot` is an object with writerId (string) and counter (number).
146
+ * @param {Record<string, unknown>} op
147
+ */
148
+ function requireDot(op) {
149
+ const { dot } = op;
150
+ if (!dot || typeof dot !== 'object') {
151
+ throw new PatchError(
152
+ `${op.type} op requires 'dot' to be an object, got ${typeof dot}`,
153
+ { context: { opType: op.type, field: 'dot', actual: typeof dot } },
154
+ );
155
+ }
156
+ const d = /** @type {Record<string, unknown>} */ (dot);
157
+ if (typeof d.writerId !== 'string') {
158
+ throw new PatchError(
159
+ `${op.type} op requires 'dot.writerId' to be a string, got ${typeof d.writerId}`,
160
+ { context: { opType: op.type, field: 'dot.writerId', actual: typeof d.writerId } },
161
+ );
162
+ }
163
+ if (typeof d.counter !== 'number') {
164
+ throw new PatchError(
165
+ `${op.type} op requires 'dot.counter' to be a number, got ${typeof d.counter}`,
166
+ { context: { opType: op.type, field: 'dot.counter', actual: typeof d.counter } },
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Validates that an operation has the required fields for its type.
173
+ * Throws PatchError for malformed ops. Unknown/BlobValue types pass through
174
+ * for forward compatibility.
175
+ *
176
+ * @param {Record<string, unknown>} op
177
+ */
178
+ function validateOp(op) {
179
+ if (!op || typeof op.type !== 'string') {
180
+ throw new PatchError(
181
+ `Invalid op: expected object with string 'type', got ${op === null || op === undefined ? String(op) : typeof op.type}`,
182
+ { context: { actual: op === null || op === undefined ? String(op) : typeof op.type } },
183
+ );
184
+ }
185
+
186
+ switch (op.type) {
187
+ case 'NodeAdd':
188
+ requireString(op, 'node');
189
+ requireDot(op);
190
+ break;
191
+ case 'NodeRemove':
192
+ // node is optional (informational for receipts); observedDots is required for mutation
193
+ requireIterable(op, 'observedDots');
194
+ break;
195
+ case 'EdgeAdd':
196
+ requireString(op, 'from');
197
+ requireString(op, 'to');
198
+ requireString(op, 'label');
199
+ requireDot(op);
200
+ break;
201
+ case 'EdgeRemove':
202
+ // from/to/label are optional (informational for receipts); observedDots is required for mutation
203
+ requireIterable(op, 'observedDots');
204
+ break;
205
+ case 'PropSet':
206
+ requireString(op, 'node');
207
+ requireString(op, 'key');
208
+ break;
209
+ default:
210
+ // BlobValue and unknown types: no validation (forward-compat)
211
+ break;
212
+ }
213
+ }
214
+
105
215
  /**
106
216
  * Applies a single V2 operation to the given CRDT state.
107
217
  *
@@ -110,6 +220,7 @@ export function isKnownOp(op) {
110
220
  * @param {import('../utils/EventId.js').EventId} eventId - The event ID for LWW ordering
111
221
  */
112
222
  export function applyOpV2(state, op, eventId) {
223
+ validateOp(/** @type {Record<string, unknown>} */ (op));
113
224
  switch (op.type) {
114
225
  case 'NodeAdd':
115
226
  orsetAdd(state.nodeAlive, /** @type {string} */ (op.node), /** @type {import('../crdt/Dot.js').Dot} */ (op.dot));
@@ -213,21 +324,18 @@ function nodeAddOutcome(orset, op) {
213
324
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID (or '*') as target
214
325
  */
215
326
  function nodeRemoveOutcome(orset, op) {
216
- // Check if any of the observed dots are currently non-tombstoned
327
+ // Build a reverse index (dot → elementId) for the observed dots to avoid
328
+ // O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
329
+ const targetDots = op.observedDots instanceof Set
330
+ ? op.observedDots
331
+ : new Set(op.observedDots);
332
+ const dotToElement = buildDotToElement(orset, targetDots);
333
+
217
334
  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
- }
335
+ for (const encodedDot of targetDots) {
336
+ if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
337
+ effective = true;
338
+ break;
231
339
  }
232
340
  }
233
341
  const target = op.node || '*';
@@ -279,18 +387,18 @@ function edgeAddOutcome(orset, op, edgeKey) {
279
387
  * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key (or '*') as target
280
388
  */
281
389
  function edgeRemoveOutcome(orset, op) {
390
+ // Build a reverse index (dot → elementId) for the observed dots to avoid
391
+ // O(|observedDots| × |entries|) scanning. Same pattern as buildDotToElement.
392
+ const targetDots = op.observedDots instanceof Set
393
+ ? op.observedDots
394
+ : new Set(op.observedDots);
395
+ const dotToElement = buildDotToElement(orset, targetDots);
396
+
282
397
  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
- }
398
+ for (const encodedDot of targetDots) {
399
+ if (!orset.tombstones.has(encodedDot) && dotToElement.has(encodedDot)) {
400
+ effective = true;
401
+ break;
294
402
  }
295
403
  }
296
404
  // Construct target from op fields if available
@@ -603,6 +711,7 @@ export function applyWithDiff(state, patch, patchSha) {
603
711
 
604
712
  for (let i = 0; i < patch.ops.length; i++) {
605
713
  const op = patch.ops[i];
714
+ validateOp(/** @type {Record<string, unknown>} */ (op));
606
715
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
607
716
  const typedOp = /** @type {import('../types/WarpTypesV2.js').OpV2} */ (op);
608
717
  const before = snapshotBeforeOp(state, typedOp);
@@ -631,6 +740,7 @@ export function applyWithReceipt(state, patch, patchSha) {
631
740
  const opResults = [];
632
741
  for (let i = 0; i < patch.ops.length; i++) {
633
742
  const op = patch.ops[i];
743
+ validateOp(/** @type {Record<string, unknown>} */ (op));
634
744
  const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
635
745
 
636
746
  // Determine outcome BEFORE applying the op (state is pre-op)
@@ -868,16 +978,16 @@ export function reduceV5(patches, initialState, options) {
868
978
  * - Creating a branch point for speculative execution
869
979
  * - Ensuring immutability when passing state across API boundaries
870
980
  *
871
- * **Implementation Note**: OR-Sets are cloned by joining with an empty set,
872
- * which creates new data structures with identical contents.
981
+ * **Implementation Note**: OR-Sets are cloned via `orsetClone()` which
982
+ * directly copies entries and tombstones without merge logic overhead.
873
983
  *
874
984
  * @param {WarpStateV5} state - The state to clone
875
985
  * @returns {WarpStateV5} A new state with identical contents but independent data structures
876
986
  */
877
987
  export function cloneStateV5(state) {
878
988
  return {
879
- nodeAlive: orsetJoin(state.nodeAlive, createORSet()),
880
- edgeAlive: orsetJoin(state.edgeAlive, createORSet()),
989
+ nodeAlive: orsetClone(state.nodeAlive),
990
+ edgeAlive: orsetClone(state.edgeAlive),
881
991
  prop: new Map(state.prop),
882
992
  observedFrontier: vvClone(state.observedFrontier),
883
993
  edgeBirthEvent: new Map(state.edgeBirthEvent || []),
@@ -21,6 +21,9 @@ import IncrementalIndexUpdater from './IncrementalIndexUpdater.js';
21
21
  import { orsetElements, orsetContains } from '../crdt/ORSet.js';
22
22
  import { decodeEdgeKey } from './KeyCodec.js';
23
23
 
24
+ /** Prefix for property shard paths in the index tree. */
25
+ const PROPS_PREFIX = 'props_';
26
+
24
27
  /**
25
28
  * @typedef {import('./BitmapNeighborProvider.js').LogicalIndex} LogicalIndex
26
29
  */
@@ -66,7 +69,7 @@ function buildInMemoryPropertyReader(tree, codec) {
66
69
  /** @type {Record<string, string>} */
67
70
  const propShardOids = {};
68
71
  for (const path of Object.keys(tree)) {
69
- if (path.startsWith('props_')) {
72
+ if (path.startsWith(PROPS_PREFIX)) {
70
73
  propShardOids[path] = path;
71
74
  }
72
75
  }
@@ -93,7 +96,7 @@ function partitionShardOids(shardOids) {
93
96
  const propOids = {};
94
97
 
95
98
  for (const [path, oid] of Object.entries(shardOids)) {
96
- if (path.startsWith('props_')) {
99
+ if (path.startsWith(PROPS_PREFIX)) {
97
100
  propOids[path] = oid;
98
101
  } else {
99
102
  indexOids[path] = oid;
@@ -105,6 +108,10 @@ function partitionShardOids(shardOids) {
105
108
  /**
106
109
  * Mulberry32 PRNG — deterministic 32-bit generator from a seed.
107
110
  *
111
+ * mulberry32 is a fast 32-bit PRNG by Tommy Ettinger. The magic constants
112
+ * (0x6D2B79F5, shifts 15/13/16) are part of the published algorithm.
113
+ * See: https://gist.github.com/tommyettinger/46a874533244883189143505d203312c
114
+ *
108
115
  * @param {number} seed
109
116
  * @returns {() => number} Returns values in [0, 1)
110
117
  */
@@ -134,6 +141,10 @@ function sampleNodes(allNodes, sampleRate, seed) {
134
141
  }
135
142
  const rng = mulberry32(seed);
136
143
  const sampled = allNodes.filter(() => rng() < sampleRate);
144
+ // When the initial sample is empty (e.g., graph has fewer nodes than
145
+ // sample size), we fall back to using all available nodes. This changes
146
+ // the distribution but is acceptable since the sample is only used for
147
+ // layout heuristics.
137
148
  if (sampled.length === 0) {
138
149
  sampled.push(allNodes[Math.floor(rng() * allNodes.length)]);
139
150
  }