@git-stunts/git-warp 10.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +16 -0
- package/README.md +480 -0
- package/SECURITY.md +30 -0
- package/bin/git-warp +24 -0
- package/bin/warp-graph.js +1574 -0
- package/index.d.ts +2366 -0
- package/index.js +180 -0
- package/package.json +129 -0
- package/scripts/install-git-warp.sh +258 -0
- package/scripts/uninstall-git-warp.sh +139 -0
- package/src/domain/WarpGraph.js +3157 -0
- package/src/domain/crdt/Dot.js +160 -0
- package/src/domain/crdt/LWW.js +154 -0
- package/src/domain/crdt/ORSet.js +371 -0
- package/src/domain/crdt/VersionVector.js +222 -0
- package/src/domain/entities/GraphNode.js +60 -0
- package/src/domain/errors/EmptyMessageError.js +47 -0
- package/src/domain/errors/ForkError.js +30 -0
- package/src/domain/errors/IndexError.js +23 -0
- package/src/domain/errors/OperationAbortedError.js +22 -0
- package/src/domain/errors/QueryError.js +39 -0
- package/src/domain/errors/SchemaUnsupportedError.js +17 -0
- package/src/domain/errors/ShardCorruptionError.js +56 -0
- package/src/domain/errors/ShardLoadError.js +57 -0
- package/src/domain/errors/ShardValidationError.js +61 -0
- package/src/domain/errors/StorageError.js +57 -0
- package/src/domain/errors/SyncError.js +30 -0
- package/src/domain/errors/TraversalError.js +23 -0
- package/src/domain/errors/WarpError.js +31 -0
- package/src/domain/errors/WormholeError.js +28 -0
- package/src/domain/errors/WriterError.js +39 -0
- package/src/domain/errors/index.js +21 -0
- package/src/domain/services/AnchorMessageCodec.js +99 -0
- package/src/domain/services/BitmapIndexBuilder.js +225 -0
- package/src/domain/services/BitmapIndexReader.js +435 -0
- package/src/domain/services/BoundaryTransitionRecord.js +463 -0
- package/src/domain/services/CheckpointMessageCodec.js +147 -0
- package/src/domain/services/CheckpointSerializerV5.js +281 -0
- package/src/domain/services/CheckpointService.js +384 -0
- package/src/domain/services/CommitDagTraversalService.js +156 -0
- package/src/domain/services/DagPathFinding.js +712 -0
- package/src/domain/services/DagTopology.js +239 -0
- package/src/domain/services/DagTraversal.js +245 -0
- package/src/domain/services/Frontier.js +108 -0
- package/src/domain/services/GCMetrics.js +101 -0
- package/src/domain/services/GCPolicy.js +122 -0
- package/src/domain/services/GitLogParser.js +205 -0
- package/src/domain/services/HealthCheckService.js +246 -0
- package/src/domain/services/HookInstaller.js +326 -0
- package/src/domain/services/HttpSyncServer.js +262 -0
- package/src/domain/services/IndexRebuildService.js +426 -0
- package/src/domain/services/IndexStalenessChecker.js +103 -0
- package/src/domain/services/JoinReducer.js +582 -0
- package/src/domain/services/KeyCodec.js +113 -0
- package/src/domain/services/LegacyAnchorDetector.js +67 -0
- package/src/domain/services/LogicalTraversal.js +351 -0
- package/src/domain/services/MessageCodecInternal.js +132 -0
- package/src/domain/services/MessageSchemaDetector.js +145 -0
- package/src/domain/services/MigrationService.js +55 -0
- package/src/domain/services/ObserverView.js +265 -0
- package/src/domain/services/PatchBuilderV2.js +669 -0
- package/src/domain/services/PatchMessageCodec.js +140 -0
- package/src/domain/services/ProvenanceIndex.js +337 -0
- package/src/domain/services/ProvenancePayload.js +242 -0
- package/src/domain/services/QueryBuilder.js +835 -0
- package/src/domain/services/StateDiff.js +300 -0
- package/src/domain/services/StateSerializerV5.js +156 -0
- package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
- package/src/domain/services/SyncProtocol.js +593 -0
- package/src/domain/services/TemporalQuery.js +201 -0
- package/src/domain/services/TranslationCost.js +221 -0
- package/src/domain/services/TraversalService.js +8 -0
- package/src/domain/services/WarpMessageCodec.js +29 -0
- package/src/domain/services/WarpStateIndexBuilder.js +127 -0
- package/src/domain/services/WormholeService.js +353 -0
- package/src/domain/types/TickReceipt.js +285 -0
- package/src/domain/types/WarpTypes.js +209 -0
- package/src/domain/types/WarpTypesV2.js +200 -0
- package/src/domain/utils/CachedValue.js +140 -0
- package/src/domain/utils/EventId.js +89 -0
- package/src/domain/utils/LRUCache.js +112 -0
- package/src/domain/utils/MinHeap.js +114 -0
- package/src/domain/utils/RefLayout.js +280 -0
- package/src/domain/utils/WriterId.js +205 -0
- package/src/domain/utils/cancellation.js +33 -0
- package/src/domain/utils/canonicalStringify.js +42 -0
- package/src/domain/utils/defaultClock.js +20 -0
- package/src/domain/utils/defaultCodec.js +51 -0
- package/src/domain/utils/nullLogger.js +21 -0
- package/src/domain/utils/roaring.js +181 -0
- package/src/domain/utils/shardVersion.js +9 -0
- package/src/domain/warp/PatchSession.js +217 -0
- package/src/domain/warp/Writer.js +181 -0
- package/src/hooks/post-merge.sh +60 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
- package/src/infrastructure/adapters/ClockAdapter.js +57 -0
- package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
- package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
- package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
- package/src/infrastructure/adapters/NoOpLogger.js +62 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
- package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
- package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
- package/src/infrastructure/codecs/CborCodec.js +384 -0
- package/src/ports/BlobPort.js +30 -0
- package/src/ports/ClockPort.js +25 -0
- package/src/ports/CodecPort.js +25 -0
- package/src/ports/CommitPort.js +114 -0
- package/src/ports/ConfigPort.js +31 -0
- package/src/ports/CryptoPort.js +38 -0
- package/src/ports/GraphPersistencePort.js +57 -0
- package/src/ports/HttpServerPort.js +25 -0
- package/src/ports/IndexStoragePort.js +39 -0
- package/src/ports/LoggerPort.js +68 -0
- package/src/ports/RefPort.js +51 -0
- package/src/ports/TreePort.js +51 -0
- package/src/visualization/index.js +26 -0
- package/src/visualization/layouts/converters.js +75 -0
- package/src/visualization/layouts/elkAdapter.js +86 -0
- package/src/visualization/layouts/elkLayout.js +95 -0
- package/src/visualization/layouts/index.js +29 -0
- package/src/visualization/renderers/ascii/box.js +16 -0
- package/src/visualization/renderers/ascii/check.js +271 -0
- package/src/visualization/renderers/ascii/colors.js +13 -0
- package/src/visualization/renderers/ascii/formatters.js +73 -0
- package/src/visualization/renderers/ascii/graph.js +344 -0
- package/src/visualization/renderers/ascii/history.js +335 -0
- package/src/visualization/renderers/ascii/index.js +14 -0
- package/src/visualization/renderers/ascii/info.js +245 -0
- package/src/visualization/renderers/ascii/materialize.js +255 -0
- package/src/visualization/renderers/ascii/path.js +240 -0
- package/src/visualization/renderers/ascii/progress.js +32 -0
- package/src/visualization/renderers/ascii/symbols.js +33 -0
- package/src/visualization/renderers/ascii/table.js +19 -0
- package/src/visualization/renderers/browser/index.js +1 -0
- package/src/visualization/renderers/svg/index.js +159 -0
- package/src/visualization/utils/ansi.js +14 -0
- package/src/visualization/utils/time.js +40 -0
- package/src/visualization/utils/truncate.js +40 -0
- package/src/visualization/utils/unicode.js +52 -0
|
@@ -0,0 +1,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
|
+
}
|