@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.
Files changed (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. 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
+ }