@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,582 @@
1
+ /**
2
+ * JoinReducer - WARP v5 OR-Set based reducer
3
+ *
4
+ * WarpStateV5 = {
5
+ * nodeAlive: ORSet<NodeId>, // GLOBAL OR-Set
6
+ * edgeAlive: ORSet<EdgeKey>, // GLOBAL OR-Set
7
+ * prop: Map<PropKey, LWWRegister>, // Keep v4 LWW with EventId
8
+ * observedFrontier: VersionVector
9
+ * }
10
+ */
11
+
12
+ import { createORSet, orsetAdd, orsetRemove, orsetJoin } from '../crdt/ORSet.js';
13
+ import { createVersionVector, vvMerge, vvClone, vvDeserialize } from '../crdt/VersionVector.js';
14
+ import { lwwSet, lwwMax } from '../crdt/LWW.js';
15
+ import { createEventId, compareEventIds } from '../utils/EventId.js';
16
+ import { createTickReceipt, OP_TYPES } from '../types/TickReceipt.js';
17
+ import { encodeDot } from '../crdt/Dot.js';
18
+ import { encodeEdgeKey, encodePropKey } from './KeyCodec.js';
19
+
20
+ // Re-export key codec functions for backward compatibility
21
+ export {
22
+ encodeEdgeKey, decodeEdgeKey,
23
+ encodePropKey, decodePropKey,
24
+ EDGE_PROP_PREFIX,
25
+ encodeEdgePropKey, isEdgePropKey, decodeEdgePropKey,
26
+ } from './KeyCodec.js';
27
+
28
+ /**
29
+ * @typedef {Object} WarpStateV5
30
+ * @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes
31
+ * @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges
32
+ * @property {Map<string, import('../crdt/LWW.js').LWWRegister>} prop - Properties with LWW
33
+ * @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector
34
+ * @property {Map<string, import('../utils/EventId.js').EventId>} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility)
35
+ */
36
+
37
+ /**
38
+ * Creates an empty V5 state with all CRDT structures initialized.
39
+ *
40
+ * This is the starting point for fresh materialization. The returned state has:
41
+ * - Empty `nodeAlive` OR-Set (no nodes)
42
+ * - Empty `edgeAlive` OR-Set (no edges)
43
+ * - Empty `prop` Map (no properties)
44
+ * - Zero `observedFrontier` version vector (no patches observed)
45
+ * - Empty `edgeBirthEvent` Map (no edge birth events tracked)
46
+ *
47
+ * @returns {WarpStateV5} A fresh, empty WARP state ready for patch application
48
+ */
49
+ export function createEmptyStateV5() {
50
+ return {
51
+ nodeAlive: createORSet(),
52
+ edgeAlive: createORSet(),
53
+ prop: new Map(),
54
+ observedFrontier: createVersionVector(),
55
+ edgeBirthEvent: new Map(),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Applies a single V2 operation to state.
61
+ *
62
+ * This is the core mutation function for WARP state. It handles six operation types:
63
+ * - `NodeAdd`: Adds a node to the nodeAlive OR-Set with its dot identifier
64
+ * - `NodeRemove`: Removes observed dots from the nodeAlive OR-Set (tombstoning)
65
+ * - `EdgeAdd`: Adds an edge to the edgeAlive OR-Set and tracks its birth event
66
+ * - `EdgeRemove`: Removes observed dots from the edgeAlive OR-Set (tombstoning)
67
+ * - `PropSet`: Sets a property using LWW (Last-Write-Wins) semantics based on EventId
68
+ * - `BlobValue`: No-op in state; recorded in tick receipts for provenance tracking
69
+ * - Unknown types: Silently ignored for forward compatibility
70
+ *
71
+ * **Warning**: This function mutates `state` in place. For immutable operations,
72
+ * clone the state first using `cloneStateV5()`.
73
+ *
74
+ * @param {WarpStateV5} state - The state to mutate. Modified in place.
75
+ * @param {Object} op - The operation to apply
76
+ * @param {string} op.type - One of: 'NodeAdd', 'NodeRemove', 'EdgeAdd', 'EdgeRemove', 'PropSet', 'BlobValue'
77
+ * @param {string} [op.node] - Node ID (for NodeAdd, NodeRemove, PropSet)
78
+ * @param {import('../crdt/Dot.js').Dot} [op.dot] - Dot identifier (for NodeAdd, EdgeAdd)
79
+ * @param {string[]} [op.observedDots] - Encoded dots to remove (for NodeRemove, EdgeRemove)
80
+ * @param {string} [op.from] - Source node ID (for EdgeAdd, EdgeRemove)
81
+ * @param {string} [op.to] - Target node ID (for EdgeAdd, EdgeRemove)
82
+ * @param {string} [op.label] - Edge label (for EdgeAdd, EdgeRemove)
83
+ * @param {string} [op.key] - Property key (for PropSet)
84
+ * @param {*} [op.value] - Property value (for PropSet)
85
+ * @param {import('../utils/EventId.js').EventId} eventId - Event ID for causality tracking
86
+ * @returns {void}
87
+ */
88
+ export function applyOpV2(state, op, eventId) {
89
+ switch (op.type) {
90
+ case 'NodeAdd':
91
+ orsetAdd(state.nodeAlive, op.node, op.dot);
92
+ break;
93
+ case 'NodeRemove':
94
+ orsetRemove(state.nodeAlive, op.observedDots);
95
+ break;
96
+ case 'EdgeAdd': {
97
+ const edgeKey = encodeEdgeKey(op.from, op.to, op.label);
98
+ orsetAdd(state.edgeAlive, edgeKey, op.dot);
99
+ // Track the EventId at which this edge incarnation was born.
100
+ // On re-add after remove, the greater EventId replaces the old one,
101
+ // allowing the query layer to filter out stale properties.
102
+ if (state.edgeBirthEvent) {
103
+ const prev = state.edgeBirthEvent.get(edgeKey);
104
+ if (!prev || compareEventIds(eventId, prev) > 0) {
105
+ state.edgeBirthEvent.set(edgeKey, eventId);
106
+ }
107
+ }
108
+ break;
109
+ }
110
+ case 'EdgeRemove':
111
+ orsetRemove(state.edgeAlive, op.observedDots);
112
+ break;
113
+ case 'PropSet': {
114
+ // Uses EventId-based LWW, same as v4
115
+ const key = encodePropKey(op.node, op.key);
116
+ const current = state.prop.get(key);
117
+ state.prop.set(key, lwwMax(current, lwwSet(eventId, op.value)));
118
+ break;
119
+ }
120
+ default:
121
+ // Unknown op types are silently ignored (forward-compat)
122
+ break;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Maps internal operation type names to TickReceipt-compatible operation type names.
128
+ *
129
+ * The internal representation uses "Remove" for tombstone operations, but the
130
+ * TickReceipt API uses "Tombstone" to be more explicit about CRDT semantics.
131
+ * This mapping ensures receipt consumers see the canonical operation names.
132
+ *
133
+ * Mappings:
134
+ * - NodeRemove -> NodeTombstone (CRDT tombstone semantics)
135
+ * - EdgeRemove -> EdgeTombstone (CRDT tombstone semantics)
136
+ * - All others pass through unchanged
137
+ *
138
+ * @const {Object<string, string>}
139
+ */
140
+ const RECEIPT_OP_TYPE = {
141
+ NodeAdd: 'NodeAdd',
142
+ NodeRemove: 'NodeTombstone',
143
+ EdgeAdd: 'EdgeAdd',
144
+ EdgeRemove: 'EdgeTombstone',
145
+ PropSet: 'PropSet',
146
+ BlobValue: 'BlobValue',
147
+ };
148
+
149
+ /**
150
+ * Set of valid receipt op types (from TickReceipt) for fast membership checks.
151
+ * Used to filter out forward-compatible unknown operation types from receipts.
152
+ * @const {Set<string>}
153
+ */
154
+ const VALID_RECEIPT_OPS = new Set(OP_TYPES);
155
+
156
+ /**
157
+ * Determines the receipt outcome for a NodeAdd operation.
158
+ *
159
+ * Checks if the node's dot already exists in the OR-Set to determine whether
160
+ * this add operation is effective or redundant (idempotent re-delivery).
161
+ *
162
+ * @param {import('../crdt/ORSet.js').ORSet} orset - The node OR-Set containing alive nodes
163
+ * @param {Object} op - The NodeAdd operation
164
+ * @param {string} op.node - The node ID being added
165
+ * @param {import('../crdt/Dot.js').Dot} op.dot - The dot uniquely identifying this add event
166
+ * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID as target
167
+ */
168
+ function nodeAddOutcome(orset, op) {
169
+ const encoded = encodeDot(op.dot);
170
+ const existingDots = orset.entries.get(op.node);
171
+ if (existingDots && existingDots.has(encoded)) {
172
+ return { target: op.node, result: 'redundant' };
173
+ }
174
+ return { target: op.node, result: 'applied' };
175
+ }
176
+
177
+ /**
178
+ * Determines the receipt outcome for a NodeRemove (tombstone) operation.
179
+ *
180
+ * Checks if any of the observed dots exist in the OR-Set and are not yet tombstoned.
181
+ * A remove is only effective if it actually removes at least one existing, non-tombstoned dot.
182
+ * This implements OR-Set remove semantics where removes only affect dots that were
183
+ * observed at the time the remove was issued.
184
+ *
185
+ * @param {import('../crdt/ORSet.js').ORSet} orset - The node OR-Set containing alive nodes
186
+ * @param {Object} op - The NodeRemove operation
187
+ * @param {string} [op.node] - The node ID being removed (may be absent for dot-only removes)
188
+ * @param {string[]} op.observedDots - Array of encoded dots that were observed when the remove was issued
189
+ * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with node ID (or '*') as target
190
+ */
191
+ function nodeRemoveOutcome(orset, op) {
192
+ // Check if any of the observed dots are currently non-tombstoned
193
+ let effective = false;
194
+ for (const encodedDot of op.observedDots) {
195
+ if (!orset.tombstones.has(encodedDot)) {
196
+ // This dot exists and is not yet tombstoned, so the remove is effective
197
+ // Check if any entry actually has this dot
198
+ for (const dots of orset.entries.values()) {
199
+ if (dots.has(encodedDot)) {
200
+ effective = true;
201
+ break;
202
+ }
203
+ }
204
+ if (effective) {
205
+ break;
206
+ }
207
+ }
208
+ }
209
+ const target = op.node || '*';
210
+ return { target, result: effective ? 'applied' : 'redundant' };
211
+ }
212
+
213
+ /**
214
+ * Determines the receipt outcome for an EdgeAdd operation.
215
+ *
216
+ * Checks if the edge's dot already exists in the OR-Set to determine whether
217
+ * this add operation is effective or redundant (idempotent re-delivery).
218
+ * Unlike nodes, edges are keyed by the composite (from, to, label) tuple.
219
+ *
220
+ * @param {import('../crdt/ORSet.js').ORSet} orset - The edge OR-Set containing alive edges
221
+ * @param {Object} op - The EdgeAdd operation
222
+ * @param {string} op.from - Source node ID
223
+ * @param {string} op.to - Target node ID
224
+ * @param {string} op.label - Edge label
225
+ * @param {import('../crdt/Dot.js').Dot} op.dot - The dot uniquely identifying this add event
226
+ * @param {string} edgeKey - Pre-encoded edge key (from\0to\0label format)
227
+ * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key as target
228
+ */
229
+ function edgeAddOutcome(orset, op, edgeKey) {
230
+ const encoded = encodeDot(op.dot);
231
+ const existingDots = orset.entries.get(edgeKey);
232
+ if (existingDots && existingDots.has(encoded)) {
233
+ return { target: edgeKey, result: 'redundant' };
234
+ }
235
+ return { target: edgeKey, result: 'applied' };
236
+ }
237
+
238
+ /**
239
+ * Determines the receipt outcome for an EdgeRemove (tombstone) operation.
240
+ *
241
+ * Checks if any of the observed dots exist in the OR-Set and are not yet tombstoned.
242
+ * A remove is only effective if it actually removes at least one existing, non-tombstoned dot.
243
+ * This implements OR-Set remove semantics where removes only affect dots that were
244
+ * observed at the time the remove was issued.
245
+ *
246
+ * The target is computed from the operation's (from, to, label) fields if available,
247
+ * otherwise falls back to '*' for wildcard/unknown targets.
248
+ *
249
+ * @param {import('../crdt/ORSet.js').ORSet} orset - The edge OR-Set containing alive edges
250
+ * @param {Object} op - The EdgeRemove operation
251
+ * @param {string} [op.from] - Source node ID (optional for computing target)
252
+ * @param {string} [op.to] - Target node ID (optional for computing target)
253
+ * @param {string} [op.label] - Edge label (optional for computing target)
254
+ * @param {string[]} op.observedDots - Array of encoded dots that were observed when the remove was issued
255
+ * @returns {{target: string, result: 'applied'|'redundant'}} Outcome with encoded edge key (or '*') as target
256
+ */
257
+ function edgeRemoveOutcome(orset, op) {
258
+ let effective = false;
259
+ for (const encodedDot of op.observedDots) {
260
+ if (!orset.tombstones.has(encodedDot)) {
261
+ for (const dots of orset.entries.values()) {
262
+ if (dots.has(encodedDot)) {
263
+ effective = true;
264
+ break;
265
+ }
266
+ }
267
+ if (effective) {
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ // Construct target from op fields if available
273
+ const target = (op.from && op.to && op.label)
274
+ ? encodeEdgeKey(op.from, op.to, op.label)
275
+ : '*';
276
+ return { target, result: effective ? 'applied' : 'redundant' };
277
+ }
278
+
279
+ /**
280
+ * Determines the receipt outcome for a PropSet operation.
281
+ *
282
+ * Uses LWW (Last-Write-Wins) semantics to determine whether the incoming property
283
+ * value wins over any existing value. The comparison is based on EventId ordering:
284
+ * 1. Higher Lamport timestamp wins
285
+ * 2. On tie, higher writer ID wins (lexicographic)
286
+ * 3. On tie, higher patch SHA wins (lexicographic)
287
+ *
288
+ * Possible outcomes:
289
+ * - `applied`: The incoming value wins (no existing value or higher EventId)
290
+ * - `superseded`: An existing value with higher EventId wins
291
+ * - `redundant`: Exact same write (identical EventId)
292
+ *
293
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister>} propMap - The properties map keyed by encoded prop keys
294
+ * @param {Object} op - The PropSet operation
295
+ * @param {string} op.node - Node ID owning the property
296
+ * @param {string} op.key - Property key/name
297
+ * @param {*} op.value - Property value to set
298
+ * @param {import('../utils/EventId.js').EventId} eventId - The event ID for this operation, used for LWW comparison
299
+ * @returns {{target: string, result: 'applied'|'superseded'|'redundant', reason?: string}}
300
+ * Outcome with encoded prop key as target; includes reason when superseded
301
+ */
302
+ function propSetOutcome(propMap, op, eventId) {
303
+ const key = encodePropKey(op.node, op.key);
304
+ const current = propMap.get(key);
305
+ const target = key;
306
+
307
+ if (!current) {
308
+ // No existing value -- this write wins
309
+ return { target, result: 'applied' };
310
+ }
311
+
312
+ // Compare the incoming EventId with the existing register's EventId
313
+ const cmp = compareEventIds(eventId, current.eventId);
314
+ if (cmp > 0) {
315
+ // Incoming write wins
316
+ return { target, result: 'applied' };
317
+ }
318
+ if (cmp < 0) {
319
+ // Existing write wins
320
+ const winner = current.eventId;
321
+ return {
322
+ target,
323
+ result: 'superseded',
324
+ reason: `LWW: writer ${winner.writerId} at lamport ${winner.lamport} wins`,
325
+ };
326
+ }
327
+ // Same EventId -- redundant (exact same write)
328
+ return { target, result: 'redundant' };
329
+ }
330
+
331
+ /**
332
+ * Joins a patch into state, applying all operations in order.
333
+ *
334
+ * This is the primary function for incorporating a single patch into WARP state.
335
+ * It iterates through all operations in the patch, creates EventIds for causality
336
+ * tracking, and applies each operation using `applyOpV2`.
337
+ *
338
+ * **Receipt Collection Mode**:
339
+ * When `collectReceipts` is true, this function also computes the outcome of each
340
+ * operation (applied, redundant, or superseded) and returns a TickReceipt for
341
+ * provenance tracking. This has a small performance cost, so it's disabled by default.
342
+ *
343
+ * **Warning**: This function mutates `state` in place. For immutable operations,
344
+ * clone the state first using `cloneStateV5()`.
345
+ *
346
+ * @param {WarpStateV5} state - The state to mutate. Modified in place.
347
+ * @param {Object} patch - The patch to apply
348
+ * @param {string} patch.writer - Writer ID who created this patch
349
+ * @param {number} patch.lamport - Lamport timestamp of this patch
350
+ * @param {Object[]} patch.ops - Array of operations to apply
351
+ * @param {Map|Object} patch.context - Version vector context (Map or serialized form)
352
+ * @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation)
353
+ * @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data
354
+ * @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}}
355
+ * Returns mutated state directly when collectReceipts is false;
356
+ * returns {state, receipt} object when collectReceipts is true
357
+ */
358
+ export function join(state, patch, patchSha, collectReceipts) {
359
+ // ZERO-COST: when collectReceipts is falsy, skip all receipt logic
360
+ if (!collectReceipts) {
361
+ for (let i = 0; i < patch.ops.length; i++) {
362
+ const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
363
+ applyOpV2(state, patch.ops[i], eventId);
364
+ }
365
+ const contextVV = patch.context instanceof Map
366
+ ? patch.context
367
+ : vvDeserialize(patch.context);
368
+ state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
369
+ return state;
370
+ }
371
+
372
+ // Receipt-enabled path
373
+ const opResults = [];
374
+ for (let i = 0; i < patch.ops.length; i++) {
375
+ const op = patch.ops[i];
376
+ const eventId = createEventId(patch.lamport, patch.writer, patchSha, i);
377
+
378
+ // Determine outcome BEFORE applying the op (state is pre-op)
379
+ let outcome;
380
+ switch (op.type) {
381
+ case 'NodeAdd':
382
+ outcome = nodeAddOutcome(state.nodeAlive, op);
383
+ break;
384
+ case 'NodeRemove':
385
+ outcome = nodeRemoveOutcome(state.nodeAlive, op);
386
+ break;
387
+ case 'EdgeAdd': {
388
+ const edgeKey = encodeEdgeKey(op.from, op.to, op.label);
389
+ outcome = edgeAddOutcome(state.edgeAlive, op, edgeKey);
390
+ break;
391
+ }
392
+ case 'EdgeRemove':
393
+ outcome = edgeRemoveOutcome(state.edgeAlive, op);
394
+ break;
395
+ case 'PropSet':
396
+ outcome = propSetOutcome(state.prop, op, eventId);
397
+ break;
398
+ default:
399
+ // Unknown or BlobValue — always applied
400
+ outcome = { target: op.node || op.oid || '*', result: 'applied' };
401
+ break;
402
+ }
403
+
404
+ // Apply the op (mutates state)
405
+ applyOpV2(state, op, eventId);
406
+
407
+ const receiptOp = RECEIPT_OP_TYPE[op.type] || op.type;
408
+ // Skip unknown/forward-compatible op types that aren't valid receipt ops
409
+ if (!VALID_RECEIPT_OPS.has(receiptOp)) {
410
+ continue;
411
+ }
412
+ const entry = { op: receiptOp, target: outcome.target, result: outcome.result };
413
+ if (outcome.reason) {
414
+ entry.reason = outcome.reason;
415
+ }
416
+ opResults.push(entry);
417
+ }
418
+
419
+ const contextVV = patch.context instanceof Map
420
+ ? patch.context
421
+ : vvDeserialize(patch.context);
422
+ state.observedFrontier = vvMerge(state.observedFrontier, contextVV);
423
+
424
+ const receipt = createTickReceipt({
425
+ patchSha,
426
+ writer: patch.writer,
427
+ lamport: patch.lamport,
428
+ ops: opResults,
429
+ });
430
+
431
+ return { state, receipt };
432
+ }
433
+
434
+ /**
435
+ * Joins two V5 states together using CRDT merge semantics.
436
+ *
437
+ * This function implements the state-based CRDT join operation for WARP state.
438
+ * Each component is merged using its appropriate CRDT join:
439
+ * - `nodeAlive` and `edgeAlive`: OR-Set join (union of dots, tombstones)
440
+ * - `prop`: LWW-Max per property key (higher EventId wins)
441
+ * - `observedFrontier`: Version vector merge (component-wise max)
442
+ * - `edgeBirthEvent`: EventId max per edge key
443
+ *
444
+ * This is a pure function that does not mutate its inputs.
445
+ * The result is deterministic regardless of the order of arguments (commutativity).
446
+ *
447
+ * @param {WarpStateV5} a - First state to merge
448
+ * @param {WarpStateV5} b - Second state to merge
449
+ * @returns {WarpStateV5} New state representing the join of a and b
450
+ */
451
+ export function joinStates(a, b) {
452
+ return {
453
+ nodeAlive: orsetJoin(a.nodeAlive, b.nodeAlive),
454
+ edgeAlive: orsetJoin(a.edgeAlive, b.edgeAlive),
455
+ prop: mergeProps(a.prop, b.prop),
456
+ observedFrontier: vvMerge(a.observedFrontier, b.observedFrontier),
457
+ edgeBirthEvent: mergeEdgeBirthEvent(a.edgeBirthEvent, b.edgeBirthEvent),
458
+ };
459
+ }
460
+
461
+ /**
462
+ * Merges two property maps using LWW-Max semantics per key.
463
+ *
464
+ * For each property key present in either map, the resulting map contains
465
+ * the register with the greater EventId (using LWW comparison). This ensures
466
+ * deterministic merge regardless of the order in which states are joined.
467
+ *
468
+ * This is a pure function that does not mutate its inputs.
469
+ *
470
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister>} a - First property map
471
+ * @param {Map<string, import('../crdt/LWW.js').LWWRegister>} b - Second property map
472
+ * @returns {Map<string, import('../crdt/LWW.js').LWWRegister>} New map containing merged properties
473
+ */
474
+ function mergeProps(a, b) {
475
+ const result = new Map(a);
476
+
477
+ for (const [key, regB] of b) {
478
+ const regA = result.get(key);
479
+ result.set(key, lwwMax(regA, regB));
480
+ }
481
+
482
+ return result;
483
+ }
484
+
485
+ /**
486
+ * Merges two edgeBirthEvent maps by taking the greater EventId per edge key.
487
+ *
488
+ * The edgeBirthEvent map tracks when each edge was most recently added (born),
489
+ * which is used by the query layer to filter out stale properties from previous
490
+ * edge incarnations. When an edge is removed and re-added, properties from
491
+ * before the re-add should not be visible.
492
+ *
493
+ * This function handles null/undefined inputs gracefully, treating them as empty maps.
494
+ * For each edge key present in either map, the resulting map contains the greater
495
+ * EventId (using EventId comparison).
496
+ *
497
+ * This is a pure function that does not mutate its inputs.
498
+ *
499
+ * @param {Map<string, import('../utils/EventId.js').EventId>|null|undefined} a - First edge birth event map
500
+ * @param {Map<string, import('../utils/EventId.js').EventId>|null|undefined} b - Second edge birth event map
501
+ * @returns {Map<string, import('../utils/EventId.js').EventId>} New map containing merged edge birth events
502
+ */
503
+ function mergeEdgeBirthEvent(a, b) {
504
+ const result = new Map(a || []);
505
+ if (b) {
506
+ for (const [key, eventId] of b) {
507
+ const existing = result.get(key);
508
+ if (!existing || compareEventIds(eventId, existing) > 0) {
509
+ result.set(key, eventId);
510
+ }
511
+ }
512
+ }
513
+ return result;
514
+ }
515
+
516
+ /**
517
+ * Reduces an array of patches to a V5 state by applying them sequentially.
518
+ *
519
+ * This is the main materialization function that replays a sequence of patches
520
+ * to compute the current graph state. It supports both fresh materialization
521
+ * (starting from empty state) and incremental materialization (starting from
522
+ * a checkpoint state).
523
+ *
524
+ * **Performance Notes**:
525
+ * - When `options.receipts` is false (default), receipt computation is completely
526
+ * skipped, resulting in zero overhead for the common read path.
527
+ * - When `options.receipts` is true, returns a TickReceipt per patch for
528
+ * provenance tracking and debugging.
529
+ *
530
+ * @param {Array<{patch: Object, sha: string}>} patches - Array of patch objects with their Git SHAs
531
+ * @param {Object} patches[].patch - The decoded patch object (writer, lamport, ops, context)
532
+ * @param {string} patches[].sha - The Git SHA of the patch commit
533
+ * @param {WarpStateV5} [initialState] - Optional starting state (for incremental materialization from checkpoint)
534
+ * @param {Object} [options] - Optional configuration
535
+ * @param {boolean} [options.receipts=false] - When true, collect and return TickReceipts
536
+ * @returns {WarpStateV5|{state: WarpStateV5, receipts: import('../types/TickReceipt.js').TickReceipt[]}}
537
+ * Returns state directly when receipts is false;
538
+ * returns {state, receipts} when receipts is true
539
+ */
540
+ export function reduceV5(patches, initialState, options) {
541
+ const state = initialState ? cloneStateV5(initialState) : createEmptyStateV5();
542
+
543
+ // ZERO-COST: only check options when provided and truthy
544
+ if (options && options.receipts) {
545
+ const receipts = [];
546
+ for (const { patch, sha } of patches) {
547
+ const result = join(state, patch, sha, true);
548
+ receipts.push(result.receipt);
549
+ }
550
+ return { state, receipts };
551
+ }
552
+
553
+ for (const { patch, sha } of patches) {
554
+ join(state, patch, sha);
555
+ }
556
+ return state;
557
+ }
558
+
559
+ /**
560
+ * Creates a deep clone of a V5 state.
561
+ *
562
+ * All mutable components are cloned to ensure the returned state is fully
563
+ * independent of the input. This is useful for:
564
+ * - Preserving a checkpoint state before applying more patches
565
+ * - Creating a branch point for speculative execution
566
+ * - Ensuring immutability when passing state across API boundaries
567
+ *
568
+ * **Implementation Note**: OR-Sets are cloned by joining with an empty set,
569
+ * which creates new data structures with identical contents.
570
+ *
571
+ * @param {WarpStateV5} state - The state to clone
572
+ * @returns {WarpStateV5} A new state with identical contents but independent data structures
573
+ */
574
+ export function cloneStateV5(state) {
575
+ return {
576
+ nodeAlive: orsetJoin(state.nodeAlive, createORSet()),
577
+ edgeAlive: orsetJoin(state.edgeAlive, createORSet()),
578
+ prop: new Map(state.prop),
579
+ observedFrontier: vvClone(state.observedFrontier),
580
+ edgeBirthEvent: new Map(state.edgeBirthEvent || []),
581
+ };
582
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Key encoding/decoding for WARP graph CRDT state maps.
3
+ *
4
+ * Single source of truth for encoding composite keys (edges, properties,
5
+ * edge properties) used as Map keys in WarpStateV5. Uses null character
6
+ * (\0) as field separator and \x01 prefix for edge property keys.
7
+ *
8
+ * @module domain/services/KeyCodec
9
+ */
10
+
11
+ /** Field separator used in all encoded keys. */
12
+ export const FIELD_SEPARATOR = '\0';
13
+
14
+ /**
15
+ * Prefix byte for edge property keys. Guarantees no collision with node
16
+ * property keys (which start with a node-ID character, never \x01).
17
+ * @const {string}
18
+ */
19
+ export const EDGE_PROP_PREFIX = '\x01';
20
+
21
+ /**
22
+ * Encodes an edge key to a string for Map storage.
23
+ *
24
+ * @param {string} from - Source node ID
25
+ * @param {string} to - Target node ID
26
+ * @param {string} label - Edge label/type
27
+ * @returns {string} Encoded edge key in format "from\0to\0label"
28
+ * @see decodeEdgeKey - The inverse operation
29
+ */
30
+ export function encodeEdgeKey(from, to, label) {
31
+ return `${from}\0${to}\0${label}`;
32
+ }
33
+
34
+ /**
35
+ * Decodes an edge key string back to its component parts.
36
+ *
37
+ * @param {string} key - Encoded edge key in format "from\0to\0label"
38
+ * @returns {{from: string, to: string, label: string}}
39
+ * @see encodeEdgeKey - The inverse operation
40
+ */
41
+ export function decodeEdgeKey(key) {
42
+ const [from, to, label] = key.split('\0');
43
+ return { from, to, label };
44
+ }
45
+
46
+ /**
47
+ * Encodes a node property key for Map storage.
48
+ *
49
+ * @param {string} nodeId - The ID of the node owning the property
50
+ * @param {string} propKey - The property name/key
51
+ * @returns {string} Encoded property key in format "nodeId\0propKey"
52
+ * @see decodePropKey - The inverse operation
53
+ */
54
+ export function encodePropKey(nodeId, propKey) {
55
+ return `${nodeId}\0${propKey}`;
56
+ }
57
+
58
+ /**
59
+ * Decodes a node property key string back to its component parts.
60
+ *
61
+ * @param {string} key - Encoded property key in format "nodeId\0propKey"
62
+ * @returns {{nodeId: string, propKey: string}}
63
+ * @see encodePropKey - The inverse operation
64
+ */
65
+ export function decodePropKey(key) {
66
+ const [nodeId, propKey] = key.split('\0');
67
+ return { nodeId, propKey };
68
+ }
69
+
70
+ /**
71
+ * Encodes an edge property key for Map storage.
72
+ *
73
+ * Format: `\x01from\0to\0label\0propKey`
74
+ *
75
+ * The \x01 prefix guarantees collision-freedom with node property keys.
76
+ *
77
+ * @param {string} from - Source node ID
78
+ * @param {string} to - Target node ID
79
+ * @param {string} label - Edge label
80
+ * @param {string} propKey - Property name
81
+ * @returns {string}
82
+ */
83
+ export function encodeEdgePropKey(from, to, label, propKey) {
84
+ return `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}\0${propKey}`;
85
+ }
86
+
87
+ /**
88
+ * Returns true if the encoded key is an edge property key.
89
+ * @param {string} key - Encoded property key
90
+ * @returns {boolean}
91
+ */
92
+ export function isEdgePropKey(key) {
93
+ return key[0] === EDGE_PROP_PREFIX;
94
+ }
95
+
96
+ /**
97
+ * Decodes an edge property key string.
98
+ * @param {string} encoded - Encoded edge property key (must start with \x01)
99
+ * @returns {{from: string, to: string, label: string, propKey: string}}
100
+ * @throws {Error} If the encoded key is missing the edge property prefix
101
+ * @throws {Error} If the encoded key does not contain exactly 4 segments
102
+ */
103
+ export function decodeEdgePropKey(encoded) {
104
+ if (!isEdgePropKey(encoded)) {
105
+ throw new Error('Invalid edge property key: missing prefix');
106
+ }
107
+ const parts = encoded.slice(1).split('\0');
108
+ if (parts.length !== 4) {
109
+ throw new Error('Invalid edge property key: expected 4 segments');
110
+ }
111
+ const [from, to, label, propKey] = parts;
112
+ return { from, to, label, propKey };
113
+ }