@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,593 @@
1
+ /**
2
+ * SyncProtocol - WARP V5 frontier-based per-writer chain sync.
3
+ *
4
+ * This module provides the core sync protocol for WARP V5, enabling
5
+ * efficient synchronization between nodes by comparing frontiers and
6
+ * exchanging only the patches each side is missing.
7
+ *
8
+ * **Protocol Overview**:
9
+ *
10
+ * The protocol is based on per-writer chains where each writer has
11
+ * a linear history of patches. Each writer's chain is independent,
12
+ * enabling lock-free concurrent writes. Sync works by:
13
+ *
14
+ * 1. **Frontier Exchange**: Each node sends its frontier (Map<writerId, tipSha>)
15
+ * 2. **Delta Computation**: Compare frontiers to determine what each side is missing
16
+ * 3. **Patch Transfer**: Load and transmit missing patches in chronological order
17
+ * 4. **State Application**: Apply received patches using CRDT merge semantics
18
+ *
19
+ * **Protocol Messages**:
20
+ * - `SyncRequest`: Contains requester's frontier
21
+ * - `SyncResponse`: Contains responder's frontier + patches the requester needs
22
+ *
23
+ * **Assumptions**:
24
+ * - Writer chains are linear (no forks within a single writer)
25
+ * - Patches are append-only (no history rewriting)
26
+ * - CRDT semantics ensure convergence regardless of apply order
27
+ *
28
+ * **Error Handling**:
29
+ * - Divergence detection: If a writer's chain has forked (rare, indicates bug
30
+ * or corruption), the protocol detects this and skips that writer
31
+ * - Schema compatibility: Patches are validated against known op types before apply
32
+ *
33
+ * @module domain/services/SyncProtocol
34
+ * @see WARP V5 Spec Section 11 (Network Sync)
35
+ * @see JoinReducer - CRDT merge implementation
36
+ * @see Frontier - Frontier manipulation utilities
37
+ */
38
+
39
+ import defaultCodec from '../utils/defaultCodec.js';
40
+ import { decodePatchMessage, assertOpsCompatible, SCHEMA_V3 } from './WarpMessageCodec.js';
41
+ import { join, cloneStateV5 } from './JoinReducer.js';
42
+ import { cloneFrontier, updateFrontier } from './Frontier.js';
43
+ import { vvDeserialize } from '../crdt/VersionVector.js';
44
+
45
+ // -----------------------------------------------------------------------------
46
+ // Patch Loading
47
+ // -----------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Normalizes a patch after CBOR deserialization.
51
+ *
52
+ * CBOR deserialization returns plain JavaScript objects, but the CRDT
53
+ * merge logic (JoinReducer) expects the context field to be a Map
54
+ * (VersionVector). This function performs the conversion in-place.
55
+ *
56
+ * **Mutation**: This function mutates the input patch object for efficiency.
57
+ * The original object reference is returned.
58
+ *
59
+ * @param {Object} patch - The raw decoded patch from CBOR
60
+ * @param {Object|Map} [patch.context] - The causal context (version vector).
61
+ * If present as a plain object, will be converted to a Map.
62
+ * @param {Array} patch.ops - The patch operations (not modified)
63
+ * @returns {Object} The same patch object with context converted to Map
64
+ * @private
65
+ */
66
+ function normalizePatch(patch) {
67
+ // Convert context from plain object to Map (VersionVector)
68
+ // CBOR deserialization returns plain objects, but join() expects a Map
69
+ if (patch.context && !(patch.context instanceof Map)) {
70
+ patch.context = vvDeserialize(patch.context);
71
+ }
72
+ return patch;
73
+ }
74
+
75
+ /**
76
+ * Loads a patch from a commit.
77
+ *
78
+ * WARP stores patches as Git blobs, with the blob OID embedded in the
79
+ * commit message. This function:
80
+ * 1. Reads the commit message via `showNode()`
81
+ * 2. Decodes the message to extract the patch blob OID
82
+ * 3. Reads the blob and CBOR-decodes it
83
+ * 4. Normalizes the patch (converts context to Map)
84
+ *
85
+ * **Commit message format**: The message is encoded using WarpMessageCodec
86
+ * and contains metadata (schema version, writer info) plus the patch OID.
87
+ *
88
+ * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer
89
+ * (uses CommitPort.showNode() + BlobPort.readBlob() methods)
90
+ * @param {string} sha - The 40-character commit SHA to load the patch from
91
+ * @param {Object} [options]
92
+ * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
93
+ * @returns {Promise<Object>} The decoded and normalized patch object containing:
94
+ * - `ops`: Array of patch operations
95
+ * - `context`: VersionVector (Map) of causal dependencies
96
+ * - `writerId`: The writer who created this patch
97
+ * - `lamport`: Lamport timestamp for ordering
98
+ * @throws {Error} If the commit cannot be read (invalid SHA, not found)
99
+ * @throws {Error} If the commit message cannot be decoded (malformed, wrong schema)
100
+ * @throws {Error} If the patch blob cannot be read (blob not found, I/O error)
101
+ * @throws {Error} If the patch blob cannot be CBOR-decoded (corrupted data)
102
+ * @private
103
+ */
104
+ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
105
+ const codec = codecOpt || defaultCodec;
106
+ // Read commit message to extract patch OID
107
+ const message = await persistence.showNode(sha);
108
+ const decoded = decodePatchMessage(message);
109
+
110
+ // Read and decode the patch blob
111
+ const patchBuffer = await persistence.readBlob(decoded.patchOid);
112
+ const patch = codec.decode(patchBuffer);
113
+
114
+ // Normalize the patch (convert context from object to Map)
115
+ return normalizePatch(patch);
116
+ }
117
+
118
+ /**
119
+ * Loads patches for a writer between two SHAs.
120
+ *
121
+ * Walks the commit graph backwards from `toSha` to `fromSha` (exclusive),
122
+ * collecting patches along the way. Returns them in chronological order
123
+ * (oldest first) for correct application.
124
+ *
125
+ * **Ancestry requirement**: `toSha` must be a descendant of `fromSha` in the
126
+ * writer's linear chain. If not, a divergence error is thrown. This would
127
+ * indicate either a bug (same writer forked) or data corruption.
128
+ *
129
+ * **Performance**: O(N) where N is the number of commits between fromSha and toSha.
130
+ * Each commit requires two reads: commit info (for parent) and patch blob.
131
+ *
132
+ * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer
133
+ * (uses CommitPort.getNodeInfo()/showNode() + BlobPort.readBlob() methods)
134
+ * @param {string} graphName - Graph name (used in error messages, not for lookups)
135
+ * @param {string} writerId - Writer ID (used in error messages, not for lookups)
136
+ * @param {string|null} fromSha - Start SHA (exclusive). Pass null to load ALL patches
137
+ * for this writer from the beginning of their chain.
138
+ * @param {string} toSha - End SHA (inclusive). This is typically the writer's current tip.
139
+ * @returns {Promise<Array<{patch: Object, sha: string}>>} Array of patch objects in
140
+ * chronological order (oldest first). Each entry contains:
141
+ * - `patch`: The decoded patch object
142
+ * - `sha`: The commit SHA this patch came from
143
+ * @throws {Error} If divergence is detected: "Divergence detected: {toSha} does not
144
+ * descend from {fromSha} for writer {writerId}". This indicates the writer's chain
145
+ * has forked, which should not happen under normal operation.
146
+ * @throws {Error} If any commit or patch cannot be loaded (propagated from loadPatchFromCommit)
147
+ *
148
+ * @example
149
+ * // Load patches from sha-a (exclusive) to sha-c (inclusive)
150
+ * const patches = await loadPatchRange(persistence, 'events', 'node-1', 'sha-a', 'sha-c');
151
+ * // Returns [{patch, sha: 'sha-b'}, {patch, sha: 'sha-c'}] in chronological order
152
+ *
153
+ * @example
154
+ * // Load ALL patches for a new writer
155
+ * const patches = await loadPatchRange(persistence, 'events', 'new-writer', null, tipSha);
156
+ */
157
+ export async function loadPatchRange(persistence, graphName, writerId, fromSha, toSha, { codec } = {}) {
158
+ const patches = [];
159
+ let cur = toSha;
160
+
161
+ while (cur && cur !== fromSha) {
162
+ // Load commit info to get parent
163
+ const commitInfo = await persistence.getNodeInfo(cur);
164
+
165
+ // Load patch from commit
166
+ const patch = await loadPatchFromCommit(persistence, cur, { codec });
167
+ patches.unshift({ patch, sha: cur }); // Prepend for chronological order
168
+
169
+ // Move to parent (first parent in linear chain)
170
+ cur = commitInfo.parents?.[0] ?? null;
171
+ }
172
+
173
+ // If fromSha was specified but we didn't reach it, we have divergence
174
+ if (fromSha && cur === null) {
175
+ const err = new Error(
176
+ `Divergence detected: ${toSha} does not descend from ${fromSha} for writer ${writerId}`
177
+ );
178
+ err.code = 'E_SYNC_DIVERGENCE';
179
+ throw err;
180
+ }
181
+
182
+ return patches;
183
+ }
184
+
185
+ // -----------------------------------------------------------------------------
186
+ // Sync Delta Computation
187
+ // -----------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Computes what patches each side needs based on frontiers.
191
+ *
192
+ * This is the core delta computation for sync. By comparing frontiers
193
+ * (which writer SHAs each side has), we determine:
194
+ * - What local needs from remote (to catch up)
195
+ * - What remote needs from local (to catch up)
196
+ * - Which writers are completely new to each side
197
+ *
198
+ * **Algorithm**:
199
+ * 1. For each writer in remote frontier:
200
+ * - Not in local? Local needs all patches (from: null)
201
+ * - Different SHA? Local needs patches from its SHA to remote's SHA
202
+ * 2. For each writer in local frontier:
203
+ * - Not in remote? Remote needs all patches (from: null)
204
+ * - Different SHA and not already in needFromRemote? Remote needs patches
205
+ *
206
+ * **Assumptions**:
207
+ * - When SHAs differ, we assume remote is ahead. The actual ancestry
208
+ * is verified during loadPatchRange() which will throw on divergence.
209
+ * - Writers with identical SHAs in both frontiers are already in sync.
210
+ *
211
+ * **Pure function**: Does not modify inputs or perform I/O.
212
+ *
213
+ * @param {Map<string, string>} localFrontier - Local writer heads.
214
+ * Maps writerId to the SHA of their latest patch commit.
215
+ * @param {Map<string, string>} remoteFrontier - Remote writer heads.
216
+ * Maps writerId to the SHA of their latest patch commit.
217
+ * @returns {Object} Sync delta containing:
218
+ * - `needFromRemote`: Map<writerId, {from: string|null, to: string}> - Patches local needs
219
+ * - `needFromLocal`: Map<writerId, {from: string|null, to: string}> - Patches remote needs
220
+ * - `newWritersForLocal`: string[] - Writers that local has never seen
221
+ * - `newWritersForRemote`: string[] - Writers that remote has never seen
222
+ *
223
+ * @example
224
+ * const local = new Map([['w1', 'sha-a'], ['w2', 'sha-b']]);
225
+ * const remote = new Map([['w1', 'sha-c'], ['w3', 'sha-d']]);
226
+ * const delta = computeSyncDelta(local, remote);
227
+ * // delta.needFromRemote: Map { 'w1' => {from: 'sha-a', to: 'sha-c'}, 'w3' => {from: null, to: 'sha-d'} }
228
+ * // delta.needFromLocal: Map { 'w2' => {from: null, to: 'sha-b'} }
229
+ * // delta.newWritersForLocal: ['w3']
230
+ * // delta.newWritersForRemote: ['w2']
231
+ */
232
+ export function computeSyncDelta(localFrontier, remoteFrontier) {
233
+ const needFromRemote = new Map();
234
+ const needFromLocal = new Map();
235
+ const newWritersForLocal = [];
236
+ const newWritersForRemote = [];
237
+
238
+ // Check what local needs from remote
239
+ for (const [writerId, remoteSha] of remoteFrontier) {
240
+ const localSha = localFrontier.get(writerId);
241
+
242
+ if (localSha === undefined) {
243
+ // New writer for local - need all patches
244
+ needFromRemote.set(writerId, { from: null, to: remoteSha });
245
+ newWritersForLocal.push(writerId);
246
+ } else if (localSha !== remoteSha) {
247
+ // Different heads - local needs patches from its head to remote head
248
+ // Note: We assume remote is ahead; the caller should verify ancestry
249
+ needFromRemote.set(writerId, { from: localSha, to: remoteSha });
250
+ }
251
+ // If localSha === remoteSha, already in sync for this writer
252
+ }
253
+
254
+ // Check what remote needs from local
255
+ for (const [writerId, localSha] of localFrontier) {
256
+ const remoteSha = remoteFrontier.get(writerId);
257
+
258
+ if (remoteSha === undefined) {
259
+ // New writer for remote - need all patches
260
+ needFromLocal.set(writerId, { from: null, to: localSha });
261
+ newWritersForRemote.push(writerId);
262
+ } else if (remoteSha !== localSha) {
263
+ // Different heads - remote might need patches from its head to local head
264
+ // Only add if not already in needFromRemote (avoid double-counting)
265
+ // This handles the case where local is ahead of remote
266
+ if (!needFromRemote.has(writerId)) {
267
+ needFromLocal.set(writerId, { from: remoteSha, to: localSha });
268
+ }
269
+ }
270
+ }
271
+
272
+ return {
273
+ needFromRemote,
274
+ needFromLocal,
275
+ newWritersForLocal,
276
+ newWritersForRemote,
277
+ };
278
+ }
279
+
280
+ // -----------------------------------------------------------------------------
281
+ // Sync Messages
282
+ // -----------------------------------------------------------------------------
283
+
284
+ /**
285
+ * A sync request message sent from one node to another.
286
+ *
287
+ * The requester sends its current frontier, allowing the responder to
288
+ * compute what patches the requester is missing.
289
+ *
290
+ * @typedef {Object} SyncRequest
291
+ * @property {'sync-request'} type - Message type discriminator for protocol parsing
292
+ * @property {Object.<string, string>} frontier - Requester's frontier as a plain object.
293
+ * Keys are writer IDs, values are the SHA of each writer's latest known patch.
294
+ * Converted from Map for JSON serialization.
295
+ */
296
+
297
+ /**
298
+ * A sync response message containing patches the requester needs.
299
+ *
300
+ * The responder includes its own frontier (so the requester knows what
301
+ * the responder is missing) and the patches the requester needs to catch up.
302
+ *
303
+ * @typedef {Object} SyncResponse
304
+ * @property {'sync-response'} type - Message type discriminator for protocol parsing
305
+ * @property {Object.<string, string>} frontier - Responder's frontier as a plain object.
306
+ * Keys are writer IDs, values are SHAs.
307
+ * @property {Array<{writerId: string, sha: string, patch: Object}>} patches - Patches
308
+ * the requester needs, in chronological order per writer. Contains:
309
+ * - `writerId`: The writer who created this patch
310
+ * - `sha`: The commit SHA this patch came from (for frontier updates)
311
+ * - `patch`: The decoded patch object with ops and context
312
+ */
313
+
314
+ /**
315
+ * Creates a sync request message.
316
+ *
317
+ * Converts the frontier Map to a plain object for JSON serialization.
318
+ * The resulting message can be sent over HTTP, WebSocket, or any other
319
+ * transport that supports JSON.
320
+ *
321
+ * **Wire format**: The message is a simple JSON object suitable for
322
+ * transmission. No additional encoding is required.
323
+ *
324
+ * @param {Map<string, string>} frontier - Local frontier mapping writer IDs
325
+ * to their latest known patch SHAs
326
+ * @returns {SyncRequest} A sync request message ready for serialization
327
+ *
328
+ * @example
329
+ * const frontier = new Map([['w1', 'sha-a'], ['w2', 'sha-b']]);
330
+ * const request = createSyncRequest(frontier);
331
+ * // { type: 'sync-request', frontier: { w1: 'sha-a', w2: 'sha-b' } }
332
+ * // Send over HTTP: await fetch(url, { body: JSON.stringify(request) })
333
+ */
334
+ export function createSyncRequest(frontier) {
335
+ // Convert Map to plain object for serialization
336
+ const frontierObj = {};
337
+ for (const [writerId, sha] of frontier) {
338
+ frontierObj[writerId] = sha;
339
+ }
340
+
341
+ return {
342
+ type: 'sync-request',
343
+ frontier: frontierObj,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Processes a sync request and returns patches the requester needs.
349
+ *
350
+ * This is the server-side handler for sync requests. It:
351
+ * 1. Converts the incoming frontier from plain object to Map
352
+ * 2. Computes what the requester is missing (using computeSyncDelta)
353
+ * 3. Loads the missing patches from storage
354
+ * 4. Returns a response with the local frontier and patches
355
+ *
356
+ * **Error handling**: If divergence is detected for a writer (their chain
357
+ * has forked), that writer is silently skipped. The requester will not
358
+ * receive patches for that writer and may need to handle this separately
359
+ * (e.g., full resync, manual intervention).
360
+ *
361
+ * **Performance**: O(P) where P is the total number of patches to load.
362
+ * Each patch requires reading commit info + patch blob.
363
+ *
364
+ * @param {SyncRequest} request - Incoming sync request containing the requester's frontier
365
+ * @param {Map<string, string>} localFrontier - Local frontier (what this node has)
366
+ * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence
367
+ * layer for loading patches (uses CommitPort + BlobPort methods)
368
+ * @param {string} graphName - Graph name for error messages and logging
369
+ * @returns {Promise<SyncResponse>} Response containing local frontier and patches.
370
+ * Patches are ordered chronologically within each writer.
371
+ * @throws {Error} If patch loading fails for reasons other than divergence
372
+ * (e.g., corrupted data, I/O error)
373
+ *
374
+ * @example
375
+ * // Server-side sync handler
376
+ * app.post('/sync', async (req, res) => {
377
+ * const request = req.body;
378
+ * const response = await processSyncRequest(request, localFrontier, persistence, 'events');
379
+ * res.json(response);
380
+ * });
381
+ */
382
+ export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = {}) {
383
+ // Convert incoming frontier from object to Map
384
+ const remoteFrontier = new Map(Object.entries(request.frontier));
385
+
386
+ // Compute what the requester needs
387
+ const delta = computeSyncDelta(remoteFrontier, localFrontier);
388
+
389
+ // Load patches that the requester needs (from local to requester)
390
+ const patches = [];
391
+
392
+ for (const [writerId, range] of delta.needFromRemote) {
393
+ try {
394
+ const writerPatches = await loadPatchRange(
395
+ persistence,
396
+ graphName,
397
+ writerId,
398
+ range.from,
399
+ range.to,
400
+ { codec }
401
+ );
402
+
403
+ for (const { patch, sha } of writerPatches) {
404
+ patches.push({ writerId, sha, patch });
405
+ }
406
+ } catch (err) {
407
+ // If we detect divergence, skip this writer
408
+ // The requester may need to handle this separately
409
+ if (err.code === 'E_SYNC_DIVERGENCE' || err.message.includes('Divergence detected')) {
410
+ continue;
411
+ }
412
+ throw err;
413
+ }
414
+ }
415
+
416
+ // Convert local frontier to plain object
417
+ const frontierObj = {};
418
+ for (const [writerId, sha] of localFrontier) {
419
+ frontierObj[writerId] = sha;
420
+ }
421
+
422
+ return {
423
+ type: 'sync-response',
424
+ frontier: frontierObj,
425
+ patches,
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Applies a sync response to local state.
431
+ *
432
+ * This is the client-side handler for sync responses. It:
433
+ * 1. Clones state and frontier to avoid mutating inputs
434
+ * 2. Groups patches by writer for correct ordering
435
+ * 3. Validates each patch against known op types (schema compatibility)
436
+ * 4. Applies patches using CRDT merge semantics (JoinReducer.join)
437
+ * 5. Updates the frontier with new writer tips
438
+ *
439
+ * **CRDT convergence**: Patches can be applied in any order and the final
440
+ * state will be identical. However, applying in chronological order (as
441
+ * provided) is slightly more efficient.
442
+ *
443
+ * **Schema validation**: Patches are checked against SCHEMA_V3 before apply.
444
+ * If a patch contains op types we don't understand (from a newer schema),
445
+ * assertOpsCompatible throws to prevent silent data loss. The caller should
446
+ * upgrade their client before retrying.
447
+ *
448
+ * **Immutability**: This function does not modify the input state or frontier.
449
+ * It returns new objects.
450
+ *
451
+ * @param {SyncResponse} response - Incoming sync response containing patches
452
+ * @param {import('./JoinReducer.js').WarpStateV5} state - Current CRDT state
453
+ * (nodeAlive, edgeAlive, prop, observedFrontier)
454
+ * @param {Map<string, string>} frontier - Current frontier mapping writer IDs to SHAs
455
+ * @returns {Object} Result containing:
456
+ * - `state`: New WarpStateV5 with patches applied
457
+ * - `frontier`: New frontier with updated writer tips
458
+ * - `applied`: Number of patches successfully applied
459
+ * @throws {Error} If a patch contains unsupported op types (schema incompatibility).
460
+ * The error message will indicate which op type is unknown.
461
+ *
462
+ * @example
463
+ * // Client-side sync handler
464
+ * const response = await fetch('/sync', { ... }).then(r => r.json());
465
+ * const result = applySyncResponse(response, currentState, currentFrontier);
466
+ * console.log(`Applied ${result.applied} patches`);
467
+ * // Update local state
468
+ * currentState = result.state;
469
+ * currentFrontier = result.frontier;
470
+ */
471
+ export function applySyncResponse(response, state, frontier) {
472
+ // Clone state and frontier to avoid mutating inputs
473
+ const newState = cloneStateV5(state);
474
+ const newFrontier = cloneFrontier(frontier);
475
+ let applied = 0;
476
+
477
+ // Group patches by writer to ensure proper ordering
478
+ const patchesByWriter = new Map();
479
+ for (const { writerId, sha, patch } of response.patches) {
480
+ if (!patchesByWriter.has(writerId)) {
481
+ patchesByWriter.set(writerId, []);
482
+ }
483
+ patchesByWriter.get(writerId).push({ sha, patch });
484
+ }
485
+
486
+ // Apply patches for each writer
487
+ for (const [writerId, writerPatches] of patchesByWriter) {
488
+ // Patches should already be in chronological order from processSyncRequest
489
+ for (const { sha, patch } of writerPatches) {
490
+ // Normalize patch context (in case it came from network serialization)
491
+ const normalizedPatch = normalizePatch(patch);
492
+ // Guard: reject patches containing ops we don't understand.
493
+ // Currently SCHEMA_V3 is the max, so this is a no-op for this
494
+ // codebase. If a future schema adds new op types, this check
495
+ // will prevent silent data loss until the reader is upgraded.
496
+ assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
497
+ // Apply patch to state
498
+ join(newState, normalizedPatch, sha);
499
+ applied++;
500
+ }
501
+
502
+ // Update frontier to the last SHA for this writer
503
+ if (writerPatches.length > 0) {
504
+ const lastPatch = writerPatches[writerPatches.length - 1];
505
+ updateFrontier(newFrontier, writerId, lastPatch.sha);
506
+ }
507
+ }
508
+
509
+ return {
510
+ state: newState,
511
+ frontier: newFrontier,
512
+ applied,
513
+ };
514
+ }
515
+
516
+ // -----------------------------------------------------------------------------
517
+ // Utility Functions
518
+ // -----------------------------------------------------------------------------
519
+
520
+ /**
521
+ * Checks if a sync is needed between two frontiers.
522
+ *
523
+ * A fast comparison to determine if two nodes have diverged. This can be
524
+ * used to skip expensive sync operations when nodes are already in sync.
525
+ *
526
+ * **Comparison logic**:
527
+ * 1. If frontier sizes differ, sync is needed (different writer sets)
528
+ * 2. If any writer has a different SHA, sync is needed
529
+ * 3. Otherwise, frontiers are identical and no sync is needed
530
+ *
531
+ * **Note**: This only checks for differences, not direction. Even if this
532
+ * returns true, it's possible that local is ahead of remote (not just behind).
533
+ *
534
+ * @param {Map<string, string>} localFrontier - Local frontier
535
+ * @param {Map<string, string>} remoteFrontier - Remote frontier
536
+ * @returns {boolean} True if frontiers differ and sync is needed
537
+ *
538
+ * @example
539
+ * if (syncNeeded(localFrontier, remoteFrontier)) {
540
+ * const request = createSyncRequest(localFrontier);
541
+ * // ... perform sync
542
+ * } else {
543
+ * console.log('Already in sync');
544
+ * }
545
+ */
546
+ export function syncNeeded(localFrontier, remoteFrontier) {
547
+ // Different number of writers means sync needed
548
+ if (localFrontier.size !== remoteFrontier.size) {
549
+ return true;
550
+ }
551
+
552
+ // Check if any writer has different head
553
+ for (const [writerId, localSha] of localFrontier) {
554
+ const remoteSha = remoteFrontier.get(writerId);
555
+ if (remoteSha !== localSha) {
556
+ return true;
557
+ }
558
+ }
559
+
560
+ return false;
561
+ }
562
+
563
+ /**
564
+ * Creates an empty sync response (used when no patches are needed).
565
+ *
566
+ * This is a convenience function for responding to sync requests when
567
+ * the requester is already up-to-date (or ahead). The response includes
568
+ * the local frontier but no patches.
569
+ *
570
+ * **Use case**: When processSyncRequest would return no patches anyway,
571
+ * this provides a more efficient path.
572
+ *
573
+ * @param {Map<string, string>} frontier - Local frontier to include in the response
574
+ * @returns {SyncResponse} A sync response with empty patches array
575
+ *
576
+ * @example
577
+ * // Shortcut when requester is already in sync
578
+ * if (!syncNeeded(remoteFrontier, localFrontier)) {
579
+ * return createEmptySyncResponse(localFrontier);
580
+ * }
581
+ */
582
+ export function createEmptySyncResponse(frontier) {
583
+ const frontierObj = {};
584
+ for (const [writerId, sha] of frontier) {
585
+ frontierObj[writerId] = sha;
586
+ }
587
+
588
+ return {
589
+ type: 'sync-response',
590
+ frontier: frontierObj,
591
+ patches: [],
592
+ };
593
+ }