@git-stunts/git-warp 12.2.0 → 12.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/bin/cli/commands/trust.js +37 -1
- package/bin/cli/infrastructure.js +14 -1
- package/bin/cli/schemas.js +4 -4
- package/bin/warp-graph.js +4 -1
- package/index.d.ts +17 -1
- package/package.json +1 -1
- package/src/domain/WarpGraph.js +1 -1
- package/src/domain/crdt/Dot.js +5 -0
- package/src/domain/crdt/LWW.js +3 -1
- package/src/domain/crdt/ORSet.js +30 -23
- package/src/domain/crdt/VersionVector.js +12 -0
- package/src/domain/errors/PatchError.js +27 -0
- package/src/domain/errors/StorageError.js +8 -0
- package/src/domain/errors/WriterError.js +5 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditVerifierService.js +32 -2
- package/src/domain/services/BitmapIndexBuilder.js +14 -9
- package/src/domain/services/CheckpointService.js +10 -1
- package/src/domain/services/GCPolicy.js +25 -4
- package/src/domain/services/GraphTraversal.js +3 -1
- package/src/domain/services/IncrementalIndexUpdater.js +179 -36
- package/src/domain/services/JoinReducer.js +141 -31
- package/src/domain/services/MaterializedViewService.js +13 -2
- package/src/domain/services/PatchBuilderV2.js +181 -142
- package/src/domain/services/QueryBuilder.js +4 -0
- package/src/domain/services/SyncController.js +12 -31
- package/src/domain/services/SyncProtocol.js +75 -32
- package/src/domain/trust/TrustRecordService.js +50 -36
- package/src/domain/utils/CachedValue.js +34 -5
- package/src/domain/utils/EventId.js +4 -1
- package/src/domain/utils/LRUCache.js +3 -1
- package/src/domain/utils/RefLayout.js +4 -0
- package/src/domain/utils/canonicalStringify.js +48 -18
- package/src/domain/utils/matchGlob.js +7 -0
- package/src/domain/warp/PatchSession.js +30 -24
- package/src/domain/warp/Writer.js +5 -0
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/checkpoint.methods.js +36 -7
- package/src/domain/warp/materialize.methods.js +44 -5
- package/src/domain/warp/materializeAdvanced.methods.js +50 -10
- package/src/domain/warp/patch.methods.js +19 -11
- package/src/infrastructure/adapters/GitGraphAdapter.js +55 -52
- package/src/infrastructure/codecs/CborCodec.js +2 -0
- package/src/domain/utils/fnv1a.js +0 -20
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
// edges
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
//
|
|
259
|
-
const
|
|
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
|
-
)
|
|
289
|
+
);
|
|
290
|
+
const targets = bm.toArray();
|
|
263
291
|
|
|
264
292
|
// Clear this node's outgoing bitmap
|
|
265
|
-
|
|
266
|
-
fwdData[bucket][gidStr] =
|
|
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
|
|
304
|
+
const targetBm = RoaringBitmap32.deserialize(
|
|
277
305
|
toBytes(revData[bucket][targetGidStr]),
|
|
278
306
|
true,
|
|
279
307
|
);
|
|
280
|
-
|
|
281
|
-
revData[bucket][targetGidStr] =
|
|
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
|
|
321
|
+
const bm = RoaringBitmap32.deserialize(
|
|
294
322
|
toBytes(revData[bucket][gidStr]),
|
|
295
323
|
true,
|
|
296
|
-
)
|
|
324
|
+
);
|
|
325
|
+
const sources = bm.toArray();
|
|
297
326
|
|
|
298
|
-
|
|
299
|
-
revData[bucket][gidStr] =
|
|
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
|
|
338
|
+
const sourceBm = RoaringBitmap32.deserialize(
|
|
310
339
|
toBytes(fwdDataPeer[bucket][sourceGidStr]),
|
|
311
340
|
true,
|
|
312
341
|
);
|
|
313
|
-
|
|
314
|
-
fwdDataPeer[bucket][sourceGidStr] =
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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] =
|
|
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
|
-
//
|
|
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
|
|
219
|
-
if (!orset.tombstones.has(encodedDot)) {
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
284
|
-
if (!orset.tombstones.has(encodedDot)) {
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
872
|
-
*
|
|
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:
|
|
880
|
-
edgeAlive:
|
|
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(
|
|
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(
|
|
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
|
}
|