@git-stunts/git-warp 10.1.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/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- package/src/visualization/utils/unicode.js +52 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Dot - Unique Operation Identity for CRDT Semantics
|
|
3
|
+
*
|
|
4
|
+
* In distributed systems, concurrent operations can arrive in any order and may
|
|
5
|
+
* conflict. To resolve conflicts deterministically without coordination, each
|
|
6
|
+
* operation must carry a unique, globally recognizable identity. This is the
|
|
7
|
+
* role of a "dot."
|
|
8
|
+
*
|
|
9
|
+
* ## What is a Dot?
|
|
10
|
+
*
|
|
11
|
+
* A dot is a (writerId, counter) pair that uniquely identifies a single CRDT
|
|
12
|
+
* operation. Think of it as a "birth certificate" for an operation:
|
|
13
|
+
*
|
|
14
|
+
* - **writerId**: Identifies which writer created this operation. Each writer
|
|
15
|
+
* in the system has a unique ID (e.g., "alice", "bob", or a UUID).
|
|
16
|
+
*
|
|
17
|
+
* - **counter**: A monotonically increasing integer for this writer. Each time
|
|
18
|
+
* a writer creates an operation, it increments its counter. The first
|
|
19
|
+
* operation is counter=1, the second is counter=2, and so on.
|
|
20
|
+
*
|
|
21
|
+
* Together, (writerId, counter) forms a globally unique identifier because:
|
|
22
|
+
* 1. No two writers share the same writerId
|
|
23
|
+
* 2. No single writer uses the same counter twice
|
|
24
|
+
*
|
|
25
|
+
* ## Why Dots Matter for CRDTs
|
|
26
|
+
*
|
|
27
|
+
* Dots enable "add-wins" semantics in OR-Sets. When an element is added, the
|
|
28
|
+
* add operation's dot is recorded. When an element is removed, only the dots
|
|
29
|
+
* that the remover has *observed* are tombstoned. This means:
|
|
30
|
+
*
|
|
31
|
+
* - Concurrent add + remove: The add wins (its dot wasn't observed by the remove)
|
|
32
|
+
* - Sequential add then remove: The remove wins (it observed the add's dot)
|
|
33
|
+
* - Re-add after remove: The new add wins (new dot wasn't observed by old remove)
|
|
34
|
+
*
|
|
35
|
+
* Without dots, you cannot distinguish "concurrent add" from "re-add after
|
|
36
|
+
* remove," leading to either lost updates or zombie elements.
|
|
37
|
+
*
|
|
38
|
+
* ## Dots and Causality
|
|
39
|
+
*
|
|
40
|
+
* Dots relate to causality through version vectors. A version vector is a map
|
|
41
|
+
* from writerId to the highest counter seen from that writer. If vv[writerId]
|
|
42
|
+
* >= dot.counter, then the dot has been "observed" or "included" in that causal
|
|
43
|
+
* context.
|
|
44
|
+
*
|
|
45
|
+
* This enables:
|
|
46
|
+
* - **Causality tracking**: Know which operations have been seen
|
|
47
|
+
* - **Safe garbage collection**: Only compact dots that all replicas have seen
|
|
48
|
+
* - **Conflict detection**: Concurrent operations have dots not in each other's context
|
|
49
|
+
*
|
|
50
|
+
* ## Encoding
|
|
51
|
+
*
|
|
52
|
+
* Dots are encoded as strings "writerId:counter" for use as Map/Set keys. The
|
|
53
|
+
* lastIndexOf(':') parsing handles writerIds that contain colons.
|
|
54
|
+
*
|
|
55
|
+
* @module crdt/Dot
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Dot - Unique operation identifier for CRDT operations.
|
|
60
|
+
* A dot is a (writerId, counter) pair that uniquely identifies an operation.
|
|
61
|
+
*
|
|
62
|
+
* @typedef {Object} Dot
|
|
63
|
+
* @property {string} writerId - Writer identifier (non-empty string)
|
|
64
|
+
* @property {number} counter - Monotonic counter (positive integer)
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Creates a validated Dot.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} writerId - Must be non-empty string
|
|
71
|
+
* @param {number} counter - Must be positive integer (> 0)
|
|
72
|
+
* @returns {Dot}
|
|
73
|
+
* @throws {Error} If validation fails
|
|
74
|
+
*/
|
|
75
|
+
export function createDot(writerId, counter) {
|
|
76
|
+
if (typeof writerId !== 'string' || writerId.length === 0) {
|
|
77
|
+
throw new Error('writerId must be a non-empty string');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!Number.isInteger(counter) || counter <= 0) {
|
|
81
|
+
throw new Error('counter must be a positive integer');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { writerId, counter };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Checks if two dots are equal.
|
|
89
|
+
*
|
|
90
|
+
* @param {Dot} a
|
|
91
|
+
* @param {Dot} b
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function dotsEqual(a, b) {
|
|
95
|
+
return a.writerId === b.writerId && a.counter === b.counter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Encodes a dot as a string for use as Set/Map keys.
|
|
100
|
+
* Format: "writerId:counter"
|
|
101
|
+
*
|
|
102
|
+
* @param {Dot} dot
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
export function encodeDot(dot) {
|
|
106
|
+
return `${dot.writerId}:${dot.counter}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decodes an encoded dot string back to a Dot object.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} encoded - Format: "writerId:counter"
|
|
113
|
+
* @returns {Dot}
|
|
114
|
+
* @throws {Error} If format is invalid
|
|
115
|
+
*/
|
|
116
|
+
export function decodeDot(encoded) {
|
|
117
|
+
const lastColonIndex = encoded.lastIndexOf(':');
|
|
118
|
+
if (lastColonIndex === -1) {
|
|
119
|
+
throw new Error('Invalid encoded dot format: missing colon');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const writerId = encoded.slice(0, lastColonIndex);
|
|
123
|
+
const counterStr = encoded.slice(lastColonIndex + 1);
|
|
124
|
+
const counter = parseInt(counterStr, 10);
|
|
125
|
+
|
|
126
|
+
if (writerId.length === 0) {
|
|
127
|
+
throw new Error('Invalid encoded dot format: empty writerId');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (isNaN(counter) || counter <= 0) {
|
|
131
|
+
throw new Error('Invalid encoded dot format: invalid counter');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { writerId, counter };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compares two dots lexicographically.
|
|
139
|
+
* Order: writerId -> counter
|
|
140
|
+
*
|
|
141
|
+
* NOTE: This is ONLY for deterministic serialization ordering,
|
|
142
|
+
* NOT for "newest wins" semantics. Dots are identity, not timestamps.
|
|
143
|
+
*
|
|
144
|
+
* @param {Dot} a
|
|
145
|
+
* @param {Dot} b
|
|
146
|
+
* @returns {number} -1 if a < b, 0 if equal, 1 if a > b
|
|
147
|
+
*/
|
|
148
|
+
export function compareDots(a, b) {
|
|
149
|
+
// 1. Compare writerId as string
|
|
150
|
+
if (a.writerId !== b.writerId) {
|
|
151
|
+
return a.writerId < b.writerId ? -1 : 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 2. Compare counter numerically
|
|
155
|
+
if (a.counter !== b.counter) {
|
|
156
|
+
return a.counter < b.counter ? -1 : 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { compareEventIds } from '../utils/EventId.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview LWW Register - Last-Write-Wins with Total Ordering
|
|
5
|
+
*
|
|
6
|
+
* An LWW (Last-Write-Wins) register is a CRDT that resolves concurrent writes
|
|
7
|
+
* by keeping the write with the "greatest" timestamp. This implementation uses
|
|
8
|
+
* EventId as the timestamp, providing a deterministic total order.
|
|
9
|
+
*
|
|
10
|
+
* ## Total Ordering Guarantee
|
|
11
|
+
*
|
|
12
|
+
* Unlike wall-clock timestamps which can have ties, EventId provides a **total
|
|
13
|
+
* order** - for any two distinct EventIds, one is definitively greater than
|
|
14
|
+
* the other. This eliminates non-determinism in conflict resolution.
|
|
15
|
+
*
|
|
16
|
+
* The EventId comparison order is:
|
|
17
|
+
*
|
|
18
|
+
* 1. **Lamport timestamp** (numeric, ascending)
|
|
19
|
+
* Higher Lamport = later in causal order or concurrent with higher clock
|
|
20
|
+
*
|
|
21
|
+
* 2. **writerId** (string, lexicographic)
|
|
22
|
+
* Tie-breaker when Lamport timestamps match
|
|
23
|
+
*
|
|
24
|
+
* 3. **patchSha** (hex string, lexicographic)
|
|
25
|
+
* Tie-breaker for same writer, same Lamport (different patches)
|
|
26
|
+
*
|
|
27
|
+
* 4. **opIndex** (numeric, ascending)
|
|
28
|
+
* Tie-breaker for multiple operations within the same patch
|
|
29
|
+
*
|
|
30
|
+
* This four-level comparison ensures:
|
|
31
|
+
* - Causally-later writes generally win (via Lamport)
|
|
32
|
+
* - Concurrent writes have deterministic winners (via writerId)
|
|
33
|
+
* - All replicas agree on the winner without coordination
|
|
34
|
+
*
|
|
35
|
+
* ## Deterministic Tie-Break Behavior
|
|
36
|
+
*
|
|
37
|
+
* When EventIds are exactly equal (same lamport, writerId, patchSha, opIndex),
|
|
38
|
+
* the lwwMax function returns the first argument. This is deterministic because:
|
|
39
|
+
*
|
|
40
|
+
* - Equal EventIds mean the same operation from the same patch
|
|
41
|
+
* - The values must be identical (same operation)
|
|
42
|
+
* - Returning first argument is an arbitrary but consistent choice
|
|
43
|
+
*
|
|
44
|
+
* In practice, equal EventIds should only occur when merging a register with
|
|
45
|
+
* itself (idempotence).
|
|
46
|
+
*
|
|
47
|
+
* ## Why Lamport First?
|
|
48
|
+
*
|
|
49
|
+
* Lamport timestamps respect causality: if operation A happens-before B, then
|
|
50
|
+
* A's Lamport < B's Lamport. By sorting Lamport first:
|
|
51
|
+
*
|
|
52
|
+
* - Sequential writes are ordered correctly
|
|
53
|
+
* - "Later" concurrent writes tend to win (higher local clock)
|
|
54
|
+
* - The system exhibits intuitive "last write wins" behavior
|
|
55
|
+
*
|
|
56
|
+
* However, Lamport timestamps alone don't provide total order (concurrent
|
|
57
|
+
* operations can have the same Lamport), hence the additional tie-breakers.
|
|
58
|
+
*
|
|
59
|
+
* ## Semilattice Properties
|
|
60
|
+
*
|
|
61
|
+
* lwwMax forms a join-semilattice over LWW registers:
|
|
62
|
+
* - **Commutative**: lwwMax(a, b) === lwwMax(b, a)
|
|
63
|
+
* - **Associative**: lwwMax(lwwMax(a, b), c) === lwwMax(a, lwwMax(b, c))
|
|
64
|
+
* - **Idempotent**: lwwMax(a, a) === a
|
|
65
|
+
*
|
|
66
|
+
* These properties ensure conflict-free merging regardless of operation order.
|
|
67
|
+
*
|
|
68
|
+
* @module crdt/LWW
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* LWW Register - stores value with EventId for conflict resolution
|
|
73
|
+
* @template T
|
|
74
|
+
* @typedef {Object} LWWRegister
|
|
75
|
+
* @property {import('../utils/EventId.js').EventId} eventId
|
|
76
|
+
* @property {T} value
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Creates an LWW register with the given EventId and value.
|
|
81
|
+
* @template T
|
|
82
|
+
* @param {import('../utils/EventId.js').EventId} eventId
|
|
83
|
+
* @param {T} value
|
|
84
|
+
* @returns {LWWRegister<T>}
|
|
85
|
+
*/
|
|
86
|
+
export function lwwSet(eventId, value) {
|
|
87
|
+
return { eventId, value };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns the LWW register with the greater EventId.
|
|
92
|
+
* This is the join operation for LWW registers.
|
|
93
|
+
*
|
|
94
|
+
* ## EventId Comparison Logic
|
|
95
|
+
*
|
|
96
|
+
* Comparison proceeds through four levels until a difference is found:
|
|
97
|
+
*
|
|
98
|
+
* 1. **lamport** (number): Higher Lamport timestamp wins. This respects
|
|
99
|
+
* causality - if A happened-before B, A's Lamport < B's Lamport.
|
|
100
|
+
*
|
|
101
|
+
* 2. **writerId** (string): Lexicographic comparison. Deterministic tie-break
|
|
102
|
+
* for concurrent operations with the same Lamport clock.
|
|
103
|
+
*
|
|
104
|
+
* 3. **patchSha** (string): Lexicographic comparison of Git commit SHA.
|
|
105
|
+
* Distinguishes operations in different patches from the same writer.
|
|
106
|
+
*
|
|
107
|
+
* 4. **opIndex** (number): Numeric comparison. Distinguishes multiple
|
|
108
|
+
* property-set operations within the same patch.
|
|
109
|
+
*
|
|
110
|
+
* ## Deterministic Tie-Break
|
|
111
|
+
*
|
|
112
|
+
* On exactly equal EventIds (cmp === 0), returns the first argument `a`.
|
|
113
|
+
* This is arbitrary but deterministic - all replicas make the same choice.
|
|
114
|
+
* In practice, equal EventIds only occur when merging identical operations.
|
|
115
|
+
*
|
|
116
|
+
* ## Semilattice Properties
|
|
117
|
+
*
|
|
118
|
+
* - **Commutative**: lwwMax(a, b) === lwwMax(b, a) -- both return the one
|
|
119
|
+
* with greater EventId, or `a` on tie (same value anyway)
|
|
120
|
+
* - **Associative**: lwwMax(lwwMax(a, b), c) === lwwMax(a, lwwMax(b, c))
|
|
121
|
+
* - **Idempotent**: lwwMax(a, a) === a
|
|
122
|
+
*
|
|
123
|
+
* @template T
|
|
124
|
+
* @param {LWWRegister<T> | null | undefined} a - First register (returned on tie)
|
|
125
|
+
* @param {LWWRegister<T> | null | undefined} b - Second register
|
|
126
|
+
* @returns {LWWRegister<T> | null} Register with greater EventId, or null if both null/undefined
|
|
127
|
+
*/
|
|
128
|
+
export function lwwMax(a, b) {
|
|
129
|
+
// Handle null/undefined cases
|
|
130
|
+
if ((a === null || a === undefined) && (b === null || b === undefined)) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (a === null || a === undefined) {
|
|
134
|
+
return b;
|
|
135
|
+
}
|
|
136
|
+
if (b === null || b === undefined) {
|
|
137
|
+
return a;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Compare EventIds - return the one with greater EventId
|
|
141
|
+
// On equal EventIds, return first argument (deterministic)
|
|
142
|
+
const cmp = compareEventIds(a.eventId, b.eventId);
|
|
143
|
+
return cmp >= 0 ? a : b;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Extracts just the value from an LWW register.
|
|
148
|
+
* @template T
|
|
149
|
+
* @param {LWWRegister<T> | null | undefined} reg
|
|
150
|
+
* @returns {T | undefined}
|
|
151
|
+
*/
|
|
152
|
+
export function lwwValue(reg) {
|
|
153
|
+
return reg?.value;
|
|
154
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { encodeDot, decodeDot, compareDots } from './Dot.js';
|
|
2
|
+
import { vvContains } from './VersionVector.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @fileoverview ORSet - Observed-Remove Set with Add-Wins Semantics
|
|
6
|
+
*
|
|
7
|
+
* An ORSet (Observed-Remove Set) is a CRDT that allows concurrent add and
|
|
8
|
+
* remove operations on a set while guaranteeing convergence. This implementation
|
|
9
|
+
* uses "add-wins" semantics: when an add and remove happen concurrently, the
|
|
10
|
+
* add wins.
|
|
11
|
+
*
|
|
12
|
+
* ## Add-Wins Semantics
|
|
13
|
+
*
|
|
14
|
+
* The key insight of OR-Sets is that removals only affect adds they have
|
|
15
|
+
* *observed*. When you remove an element, you're really saying "remove all
|
|
16
|
+
* the add operations I've seen for this element." Any concurrent add (one
|
|
17
|
+
* you haven't seen) survives.
|
|
18
|
+
*
|
|
19
|
+
* This is implemented via dots:
|
|
20
|
+
* - Each add operation is tagged with a unique dot (writerId, counter)
|
|
21
|
+
* - Remove records which dots it has observed (the "observed set")
|
|
22
|
+
* - The element is present if ANY of its dots is not tombstoned
|
|
23
|
+
*
|
|
24
|
+
* Example of add-wins:
|
|
25
|
+
* ```
|
|
26
|
+
* Writer A: add("x") with dot (A,1)
|
|
27
|
+
* Writer B: (concurrently) remove("x") with observed dots {}
|
|
28
|
+
* Result: "x" is present (dot (A,1) was not observed by B's remove)
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Example of remove-wins (when add was observed):
|
|
32
|
+
* ```
|
|
33
|
+
* Writer A: add("x") with dot (A,1)
|
|
34
|
+
* Writer B: (after sync) remove("x") with observed dots {(A,1)}
|
|
35
|
+
* Result: "x" is absent (all its dots are tombstoned)
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* ## Global Tombstones
|
|
39
|
+
*
|
|
40
|
+
* This implementation uses a **global tombstone set** rather than per-element
|
|
41
|
+
* tombstones. This is an optimization for space efficiency:
|
|
42
|
+
*
|
|
43
|
+
* - **Global tombstones**: A single Set<encodedDot> holds all tombstoned dots
|
|
44
|
+
* across all elements. When checking if an element is present, we check if
|
|
45
|
+
* ANY of its dots is NOT in the global tombstone set.
|
|
46
|
+
*
|
|
47
|
+
* - **Why global**: In a graph database, nodes and edges may be added/removed
|
|
48
|
+
* many times. Per-element tombstone tracking would require storing removed
|
|
49
|
+
* dots with each element forever. Global tombstones allow efficient compaction.
|
|
50
|
+
*
|
|
51
|
+
* - **Correctness**: Tombstones are dots, not elements. A dot uniquely identifies
|
|
52
|
+
* one add operation. Tombstoning dot (A,5) only affects that specific add,
|
|
53
|
+
* not any other add of the same element with a different dot.
|
|
54
|
+
*
|
|
55
|
+
* ## Semilattice Properties
|
|
56
|
+
*
|
|
57
|
+
* orsetJoin forms a join-semilattice:
|
|
58
|
+
* - **Commutative**: orsetJoin(a, b) equals orsetJoin(b, a)
|
|
59
|
+
* - **Associative**: orsetJoin(orsetJoin(a, b), c) equals orsetJoin(a, orsetJoin(b, c))
|
|
60
|
+
* - **Idempotent**: orsetJoin(a, a) equals a
|
|
61
|
+
*
|
|
62
|
+
* The join takes the union of both entries and tombstones. This ensures:
|
|
63
|
+
* - All adds from all replicas are preserved
|
|
64
|
+
* - All removes from all replicas are preserved
|
|
65
|
+
* - Convergence regardless of merge order
|
|
66
|
+
*
|
|
67
|
+
* ## Garbage Collection Safety
|
|
68
|
+
*
|
|
69
|
+
* The orsetCompact function removes tombstoned dots to reclaim memory, but
|
|
70
|
+
* must do so safely to avoid "zombie" resurrections:
|
|
71
|
+
*
|
|
72
|
+
* **GC Safety Invariant**: A tombstoned dot may only be compacted if ALL
|
|
73
|
+
* replicas have observed it. This is tracked via the version vector: if
|
|
74
|
+
* vvContains(includedVV, dot) is true for the "included" frontier, then
|
|
75
|
+
* all replicas have seen this dot and its tombstone.
|
|
76
|
+
*
|
|
77
|
+
* **What happens if violated**: If we compact (A,5) before replica B has seen
|
|
78
|
+
* it, and B later sends an add with dot (A,5), we'd have no tombstone to
|
|
79
|
+
* suppress it, causing a resurrection.
|
|
80
|
+
*
|
|
81
|
+
* @module crdt/ORSet
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* ORSet (Observed-Remove Set) - A CRDT set that supports add and remove operations.
|
|
86
|
+
*
|
|
87
|
+
* This is a GLOBAL OR-Set (one per category, not per element). It tracks:
|
|
88
|
+
* - entries: Map<element, Set<encodedDot>> - elements and the dots that added them
|
|
89
|
+
* - tombstones: Set<encodedDot> - global tombstones for removed dots
|
|
90
|
+
*
|
|
91
|
+
* An element is present if it has at least one non-tombstoned dot.
|
|
92
|
+
*
|
|
93
|
+
* @typedef {Object} ORSet
|
|
94
|
+
* @property {Map<*, Set<string>>} entries - element -> dots that added it
|
|
95
|
+
* @property {Set<string>} tombstones - global tombstones
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates an empty ORSet.
|
|
100
|
+
*
|
|
101
|
+
* @returns {ORSet}
|
|
102
|
+
*/
|
|
103
|
+
export function createORSet() {
|
|
104
|
+
return {
|
|
105
|
+
entries: new Map(),
|
|
106
|
+
tombstones: new Set(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Adds an element to the ORSet with the given dot.
|
|
112
|
+
* Mutates the set.
|
|
113
|
+
*
|
|
114
|
+
* @param {ORSet} set - The ORSet to mutate
|
|
115
|
+
* @param {*} element - The element to add
|
|
116
|
+
* @param {import('./Dot.js').Dot} dot - The dot representing this add operation
|
|
117
|
+
*/
|
|
118
|
+
export function orsetAdd(set, element, dot) {
|
|
119
|
+
const encoded = encodeDot(dot);
|
|
120
|
+
|
|
121
|
+
if (!set.entries.has(element)) {
|
|
122
|
+
set.entries.set(element, new Set());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
set.entries.get(element).add(encoded);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Removes an element by adding its observed dots to the tombstones.
|
|
130
|
+
* Mutates the set.
|
|
131
|
+
*
|
|
132
|
+
* @param {ORSet} set - The ORSet to mutate
|
|
133
|
+
* @param {Set<string>} observedDots - The encoded dots to tombstone
|
|
134
|
+
*/
|
|
135
|
+
export function orsetRemove(set, observedDots) {
|
|
136
|
+
for (const encodedDot of observedDots) {
|
|
137
|
+
set.tombstones.add(encodedDot);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Checks if an element is present in the ORSet.
|
|
143
|
+
* An element is present if it has at least one non-tombstoned dot.
|
|
144
|
+
*
|
|
145
|
+
* @param {ORSet} set - The ORSet to check
|
|
146
|
+
* @param {*} element - The element to check
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/
|
|
149
|
+
export function orsetContains(set, element) {
|
|
150
|
+
const dots = set.entries.get(element);
|
|
151
|
+
if (!dots) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const encodedDot of dots) {
|
|
156
|
+
if (!set.tombstones.has(encodedDot)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns all present elements in the ORSet.
|
|
166
|
+
* Only returns elements that have at least one non-tombstoned dot.
|
|
167
|
+
*
|
|
168
|
+
* @param {ORSet} set - The ORSet
|
|
169
|
+
* @returns {Array<*>} Array of present elements
|
|
170
|
+
*/
|
|
171
|
+
export function orsetElements(set) {
|
|
172
|
+
const result = [];
|
|
173
|
+
|
|
174
|
+
for (const element of set.entries.keys()) {
|
|
175
|
+
if (orsetContains(set, element)) {
|
|
176
|
+
result.push(element);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns the non-tombstoned dots for an element.
|
|
185
|
+
*
|
|
186
|
+
* @param {ORSet} set - The ORSet
|
|
187
|
+
* @param {*} element - The element
|
|
188
|
+
* @returns {Set<string>} Set of encoded dots that are not tombstoned
|
|
189
|
+
*/
|
|
190
|
+
export function orsetGetDots(set, element) {
|
|
191
|
+
const dots = set.entries.get(element);
|
|
192
|
+
if (!dots) {
|
|
193
|
+
return new Set();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = new Set();
|
|
197
|
+
for (const encodedDot of dots) {
|
|
198
|
+
if (!set.tombstones.has(encodedDot)) {
|
|
199
|
+
result.add(encodedDot);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Joins two ORSets by taking the union of entries and tombstones.
|
|
208
|
+
* Returns a new ORSet; does not mutate inputs.
|
|
209
|
+
*
|
|
210
|
+
* Properties:
|
|
211
|
+
* - Commutative: orsetJoin(a, b) equals orsetJoin(b, a)
|
|
212
|
+
* - Associative: orsetJoin(orsetJoin(a, b), c) equals orsetJoin(a, orsetJoin(b, c))
|
|
213
|
+
* - Idempotent: orsetJoin(a, a) equals a
|
|
214
|
+
*
|
|
215
|
+
* @param {ORSet} a
|
|
216
|
+
* @param {ORSet} b
|
|
217
|
+
* @returns {ORSet}
|
|
218
|
+
*/
|
|
219
|
+
export function orsetJoin(a, b) {
|
|
220
|
+
const result = createORSet();
|
|
221
|
+
|
|
222
|
+
// Union entries from a
|
|
223
|
+
for (const [element, dots] of a.entries) {
|
|
224
|
+
result.entries.set(element, new Set(dots));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Union entries from b
|
|
228
|
+
for (const [element, dots] of b.entries) {
|
|
229
|
+
if (!result.entries.has(element)) {
|
|
230
|
+
result.entries.set(element, new Set());
|
|
231
|
+
}
|
|
232
|
+
const resultDots = result.entries.get(element);
|
|
233
|
+
for (const dot of dots) {
|
|
234
|
+
resultDots.add(dot);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Union tombstones
|
|
239
|
+
for (const dot of a.tombstones) {
|
|
240
|
+
result.tombstones.add(dot);
|
|
241
|
+
}
|
|
242
|
+
for (const dot of b.tombstones) {
|
|
243
|
+
result.tombstones.add(dot);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Compacts the ORSet by removing tombstoned dots that are <= includedVV.
|
|
251
|
+
* Mutates the set.
|
|
252
|
+
*
|
|
253
|
+
* ## GC Safety Invariant
|
|
254
|
+
*
|
|
255
|
+
* This function implements safe garbage collection for OR-Set tombstones.
|
|
256
|
+
* The invariant is: **only compact dots that ALL replicas have observed**.
|
|
257
|
+
*
|
|
258
|
+
* The `includedVV` parameter represents the "stable frontier" - the version
|
|
259
|
+
* vector that all known replicas have reached. A dot (writerId, counter) is
|
|
260
|
+
* safe to compact if:
|
|
261
|
+
*
|
|
262
|
+
* 1. The dot is TOMBSTONED (it was removed)
|
|
263
|
+
* 2. The dot is <= includedVV (all replicas have seen it)
|
|
264
|
+
*
|
|
265
|
+
* ### Why both conditions?
|
|
266
|
+
*
|
|
267
|
+
* - **Condition 1 (tombstoned)**: Live dots must never be compacted. Removing
|
|
268
|
+
* a live dot would make the element disappear incorrectly.
|
|
269
|
+
*
|
|
270
|
+
* - **Condition 2 (<= includedVV)**: If a replica hasn't seen this dot yet,
|
|
271
|
+
* it might send it later. Without the tombstone, we'd have no record that
|
|
272
|
+
* it was deleted, causing resurrection.
|
|
273
|
+
*
|
|
274
|
+
* ### Correctness Proof Sketch
|
|
275
|
+
*
|
|
276
|
+
* After compaction of dot D:
|
|
277
|
+
* - D is removed from entries (if present)
|
|
278
|
+
* - D is removed from tombstones
|
|
279
|
+
*
|
|
280
|
+
* If replica B later sends D:
|
|
281
|
+
* - Since D <= includedVV, B has already observed D
|
|
282
|
+
* - B's state must also have D tombstoned (or never had it)
|
|
283
|
+
* - Therefore B cannot send D as a live add
|
|
284
|
+
*
|
|
285
|
+
* @param {ORSet} set - The ORSet to compact
|
|
286
|
+
* @param {import('./VersionVector.js').VersionVector} includedVV - The stable frontier version vector.
|
|
287
|
+
* All replicas are known to have observed at least this causal context.
|
|
288
|
+
*/
|
|
289
|
+
export function orsetCompact(set, includedVV) {
|
|
290
|
+
for (const [element, dots] of set.entries) {
|
|
291
|
+
for (const encodedDot of dots) {
|
|
292
|
+
const dot = decodeDot(encodedDot);
|
|
293
|
+
// Only compact if: (1) dot is tombstoned AND (2) dot <= includedVV
|
|
294
|
+
if (set.tombstones.has(encodedDot) && vvContains(includedVV, dot)) {
|
|
295
|
+
dots.delete(encodedDot);
|
|
296
|
+
set.tombstones.delete(encodedDot);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (dots.size === 0) {
|
|
300
|
+
set.entries.delete(element);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Serializes an ORSet to a plain object for CBOR encoding.
|
|
307
|
+
* Entries are sorted by element (stringified), dots within entries are sorted.
|
|
308
|
+
* Tombstones are sorted.
|
|
309
|
+
*
|
|
310
|
+
* @param {ORSet} set
|
|
311
|
+
* @returns {{entries: Array<[*, string[]]>, tombstones: string[]}}
|
|
312
|
+
*/
|
|
313
|
+
export function orsetSerialize(set) {
|
|
314
|
+
// Serialize entries: convert Map to array of [element, sortedDots]
|
|
315
|
+
const entriesArray = [];
|
|
316
|
+
for (const [element, dots] of set.entries) {
|
|
317
|
+
const sortedDots = [...dots].sort((a, b) => {
|
|
318
|
+
const dotA = decodeDot(a);
|
|
319
|
+
const dotB = decodeDot(b);
|
|
320
|
+
return compareDots(dotA, dotB);
|
|
321
|
+
});
|
|
322
|
+
entriesArray.push([element, sortedDots]);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Sort entries by element (stringified for consistency)
|
|
326
|
+
entriesArray.sort((a, b) => {
|
|
327
|
+
const keyA = String(a[0]);
|
|
328
|
+
const keyB = String(b[0]);
|
|
329
|
+
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// Serialize tombstones: sorted array
|
|
333
|
+
const sortedTombstones = [...set.tombstones].sort((a, b) => {
|
|
334
|
+
const dotA = decodeDot(a);
|
|
335
|
+
const dotB = decodeDot(b);
|
|
336
|
+
return compareDots(dotA, dotB);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
entries: entriesArray,
|
|
341
|
+
tombstones: sortedTombstones,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Deserializes a plain object back to an ORSet.
|
|
347
|
+
*
|
|
348
|
+
* @param {{entries?: Array<[*, string[]]>, tombstones?: string[]}} obj
|
|
349
|
+
* @returns {ORSet}
|
|
350
|
+
*/
|
|
351
|
+
export function orsetDeserialize(obj) {
|
|
352
|
+
const set = createORSet();
|
|
353
|
+
|
|
354
|
+
// Deserialize entries
|
|
355
|
+
if (obj.entries && Array.isArray(obj.entries)) {
|
|
356
|
+
for (const [element, dots] of obj.entries) {
|
|
357
|
+
if (Array.isArray(dots)) {
|
|
358
|
+
set.entries.set(element, new Set(dots));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Deserialize tombstones
|
|
364
|
+
if (obj.tombstones && Array.isArray(obj.tombstones)) {
|
|
365
|
+
for (const dot of obj.tombstones) {
|
|
366
|
+
set.tombstones.add(dot);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return set;
|
|
371
|
+
}
|