@git-stunts/git-warp 11.3.3 → 11.5.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/README.md +36 -1
- package/index.d.ts +36 -0
- package/index.js +2 -0
- package/package.json +3 -2
- package/src/domain/WarpGraph.js +22 -2
- package/src/domain/services/BitmapIndexReader.js +32 -10
- package/src/domain/services/CheckpointService.js +20 -1
- package/src/domain/services/JoinReducer.js +93 -42
- package/src/domain/services/KeyCodec.js +7 -0
- package/src/domain/services/PatchBuilderV2.js +54 -4
- package/src/domain/services/SyncController.js +576 -0
- package/src/domain/utils/validateShardOid.js +13 -0
- package/src/domain/warp/PatchSession.js +31 -0
- package/src/domain/warp/_internal.js +0 -9
- package/src/domain/warp/_wiredMethods.d.ts +1 -1
- package/src/domain/warp/query.methods.js +83 -1
- package/src/infrastructure/adapters/GitGraphAdapter.js +4 -1
- package/src/domain/warp/sync.methods.js +0 -554
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { orsetContains, orsetElements } from '../crdt/ORSet.js';
|
|
11
|
-
import { decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey, decodeEdgeKey } from '../services/KeyCodec.js';
|
|
11
|
+
import { decodePropKey, isEdgePropKey, decodeEdgePropKey, encodeEdgeKey, decodeEdgeKey, CONTENT_PROPERTY_KEY } from '../services/KeyCodec.js';
|
|
12
12
|
import { compareEventIds } from '../utils/EventId.js';
|
|
13
13
|
import { cloneStateV5 } from '../services/JoinReducer.js';
|
|
14
14
|
import QueryBuilder from '../services/QueryBuilder.js';
|
|
@@ -278,3 +278,85 @@ export async function translationCost(configA, configB) {
|
|
|
278
278
|
const s = /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (this._cachedState);
|
|
279
279
|
return computeTranslationCost(configA, configB, s);
|
|
280
280
|
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Gets the content blob OID for a node, or null if none is attached.
|
|
284
|
+
*
|
|
285
|
+
* @this {import('../WarpGraph.js').default}
|
|
286
|
+
* @param {string} nodeId - The node ID to check
|
|
287
|
+
* @returns {Promise<string|null>} Hex blob OID or null
|
|
288
|
+
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
289
|
+
*/
|
|
290
|
+
export async function getContentOid(nodeId) {
|
|
291
|
+
const props = await getNodeProps.call(this, nodeId);
|
|
292
|
+
if (!props) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
// getNodeProps returns a Map — use .get() for property access
|
|
296
|
+
const oid = props.get(CONTENT_PROPERTY_KEY);
|
|
297
|
+
return (typeof oid === 'string') ? oid : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Gets the content blob for a node, or null if none is attached.
|
|
302
|
+
*
|
|
303
|
+
* Returns the raw Buffer from `readBlob()`. Consumers wanting text
|
|
304
|
+
* should call `.toString('utf8')` on the result.
|
|
305
|
+
*
|
|
306
|
+
* @this {import('../WarpGraph.js').default}
|
|
307
|
+
* @param {string} nodeId - The node ID to get content for
|
|
308
|
+
* @returns {Promise<Buffer|null>} Content buffer or null
|
|
309
|
+
* @throws {Error} If the referenced blob OID is not in the object store
|
|
310
|
+
* (e.g., garbage-collected despite anchoring). Callers should handle this
|
|
311
|
+
* if operating on repos with aggressive GC or partial clones.
|
|
312
|
+
*/
|
|
313
|
+
export async function getContent(nodeId) {
|
|
314
|
+
const oid = await getContentOid.call(this, nodeId);
|
|
315
|
+
if (!oid) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
return await this._persistence.readBlob(oid);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Gets the content blob OID for an edge, or null if none is attached.
|
|
323
|
+
*
|
|
324
|
+
* @this {import('../WarpGraph.js').default}
|
|
325
|
+
* @param {string} from - Source node ID
|
|
326
|
+
* @param {string} to - Target node ID
|
|
327
|
+
* @param {string} label - Edge label
|
|
328
|
+
* @returns {Promise<string|null>} Hex blob OID or null
|
|
329
|
+
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
330
|
+
*/
|
|
331
|
+
export async function getEdgeContentOid(from, to, label) {
|
|
332
|
+
const props = await getEdgeProps.call(this, from, to, label);
|
|
333
|
+
if (!props) {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
// getEdgeProps returns a plain object — use bracket access
|
|
337
|
+
const oid = props[CONTENT_PROPERTY_KEY];
|
|
338
|
+
return (typeof oid === 'string') ? oid : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Gets the content blob for an edge, or null if none is attached.
|
|
343
|
+
*
|
|
344
|
+
* Returns the raw Buffer from `readBlob()`. Consumers wanting text
|
|
345
|
+
* should call `.toString('utf8')` on the result.
|
|
346
|
+
*
|
|
347
|
+
* @this {import('../WarpGraph.js').default}
|
|
348
|
+
* @param {string} from - Source node ID
|
|
349
|
+
* @param {string} to - Target node ID
|
|
350
|
+
* @param {string} label - Edge label
|
|
351
|
+
* @returns {Promise<Buffer|null>} Content buffer or null
|
|
352
|
+
* @throws {Error} If the referenced blob OID is not in the object store
|
|
353
|
+
* (e.g., garbage-collected despite anchoring). Callers should handle this
|
|
354
|
+
* if operating on repos with aggressive GC or partial clones.
|
|
355
|
+
*/
|
|
356
|
+
export async function getEdgeContent(from, to, label) {
|
|
357
|
+
const oid = await getEdgeContentOid.call(this, from, to, label);
|
|
358
|
+
if (!oid) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
return await this._persistence.readBlob(oid);
|
|
362
|
+
}
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
* @see {@link https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain} for Git plumbing concepts
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
+
import { Buffer } from 'node:buffer';
|
|
46
47
|
import { retry } from '@git-stunts/alfred';
|
|
47
48
|
import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
|
|
48
49
|
import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.js';
|
|
@@ -510,7 +511,9 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
510
511
|
const stream = await this.plumbing.executeStream({
|
|
511
512
|
args: ['cat-file', 'blob', oid]
|
|
512
513
|
});
|
|
513
|
-
|
|
514
|
+
const raw = await stream.collect({ asString: false });
|
|
515
|
+
// Ensure a real Node Buffer (plumbing may return Uint8Array)
|
|
516
|
+
return Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
514
517
|
}
|
|
515
518
|
|
|
516
519
|
/**
|
|
@@ -1,554 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sync methods for WarpGraph — frontier, status, sync protocol, and HTTP serve.
|
|
3
|
-
*
|
|
4
|
-
* Every function uses `this` bound to a WarpGraph instance at runtime
|
|
5
|
-
* via wireWarpMethods().
|
|
6
|
-
*
|
|
7
|
-
* @module domain/warp/sync.methods
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
SyncError,
|
|
12
|
-
OperationAbortedError,
|
|
13
|
-
QueryError,
|
|
14
|
-
E_NO_STATE_MSG,
|
|
15
|
-
DEFAULT_SYNC_SERVER_MAX_BYTES,
|
|
16
|
-
DEFAULT_SYNC_WITH_RETRIES,
|
|
17
|
-
DEFAULT_SYNC_WITH_BASE_DELAY_MS,
|
|
18
|
-
DEFAULT_SYNC_WITH_MAX_DELAY_MS,
|
|
19
|
-
DEFAULT_SYNC_WITH_TIMEOUT_MS,
|
|
20
|
-
} from './_internal.js';
|
|
21
|
-
import {
|
|
22
|
-
createSyncRequest as createSyncRequestImpl,
|
|
23
|
-
processSyncRequest as processSyncRequestImpl,
|
|
24
|
-
applySyncResponse as applySyncResponseImpl,
|
|
25
|
-
syncNeeded as syncNeededImpl,
|
|
26
|
-
} from '../services/SyncProtocol.js';
|
|
27
|
-
import { retry, timeout, RetryExhaustedError, TimeoutError } from '@git-stunts/alfred';
|
|
28
|
-
import { checkAborted } from '../utils/cancellation.js';
|
|
29
|
-
import { createFrontier, updateFrontier } from '../services/Frontier.js';
|
|
30
|
-
import { buildWriterRef } from '../utils/RefLayout.js';
|
|
31
|
-
import { collectGCMetrics } from '../services/GCMetrics.js';
|
|
32
|
-
import HttpSyncServer from '../services/HttpSyncServer.js';
|
|
33
|
-
import { signSyncRequest, canonicalizePath } from '../services/SyncAuthService.js';
|
|
34
|
-
import { isError } from '../types/WarpErrors.js';
|
|
35
|
-
|
|
36
|
-
/** @typedef {import('../types/WarpPersistence.js').CorePersistence} CorePersistence */
|
|
37
|
-
|
|
38
|
-
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Normalizes a sync endpoint path to ensure it starts with '/'.
|
|
42
|
-
* Returns '/sync' if no path is provided.
|
|
43
|
-
*
|
|
44
|
-
* @param {string|undefined|null} path - The sync path to normalize
|
|
45
|
-
* @returns {string} Normalized path starting with '/'
|
|
46
|
-
* @private
|
|
47
|
-
*/
|
|
48
|
-
function normalizeSyncPath(path) {
|
|
49
|
-
if (!path) {
|
|
50
|
-
return '/sync';
|
|
51
|
-
}
|
|
52
|
-
return path.startsWith('/') ? path : `/${path}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Builds auth headers for an outgoing sync request if auth is configured.
|
|
57
|
-
*
|
|
58
|
-
* @param {Object} params
|
|
59
|
-
* @param {{ secret: string, keyId?: string }|undefined} params.auth
|
|
60
|
-
* @param {string} params.bodyStr - Serialized request body
|
|
61
|
-
* @param {URL} params.targetUrl
|
|
62
|
-
* @param {import('../../ports/CryptoPort.js').default} params.crypto
|
|
63
|
-
* @returns {Promise<Record<string, string>>}
|
|
64
|
-
* @private
|
|
65
|
-
*/
|
|
66
|
-
async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
|
|
67
|
-
if (!auth || !auth.secret) {
|
|
68
|
-
return {};
|
|
69
|
-
}
|
|
70
|
-
const bodyBuf = new TextEncoder().encode(bodyStr);
|
|
71
|
-
return await signSyncRequest(
|
|
72
|
-
{
|
|
73
|
-
method: 'POST',
|
|
74
|
-
path: canonicalizePath(targetUrl.pathname + (targetUrl.search || '')),
|
|
75
|
-
contentType: 'application/json',
|
|
76
|
-
body: bodyBuf,
|
|
77
|
-
secret: auth.secret,
|
|
78
|
-
keyId: auth.keyId || 'default',
|
|
79
|
-
},
|
|
80
|
-
{ crypto },
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ── Exported methods ────────────────────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Returns the current frontier — a Map of writerId → tip SHA.
|
|
88
|
-
*
|
|
89
|
-
* @this {import('../WarpGraph.js').default}
|
|
90
|
-
* @returns {Promise<Map<string, string>>} Frontier map
|
|
91
|
-
* @throws {Error} If listing refs fails
|
|
92
|
-
*/
|
|
93
|
-
export async function getFrontier() {
|
|
94
|
-
const writerIds = await this.discoverWriters();
|
|
95
|
-
const frontier = createFrontier();
|
|
96
|
-
|
|
97
|
-
for (const writerId of writerIds) {
|
|
98
|
-
const writerRef = buildWriterRef(this._graphName, writerId);
|
|
99
|
-
const tipSha = await this._persistence.readRef(writerRef);
|
|
100
|
-
if (tipSha) {
|
|
101
|
-
updateFrontier(frontier, writerId, tipSha);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return frontier;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Checks whether any writer tip has changed since the last materialize.
|
|
110
|
-
*
|
|
111
|
-
* O(writers) comparison of stored writer tip SHAs against current refs.
|
|
112
|
-
* Cheap "has anything changed?" check without materialization.
|
|
113
|
-
*
|
|
114
|
-
* @this {import('../WarpGraph.js').default}
|
|
115
|
-
* @returns {Promise<boolean>} True if frontier has changed (or never materialized)
|
|
116
|
-
* @throws {Error} If listing refs fails
|
|
117
|
-
*/
|
|
118
|
-
export async function hasFrontierChanged() {
|
|
119
|
-
if (this._lastFrontier === null) {
|
|
120
|
-
return true;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const current = await this.getFrontier();
|
|
124
|
-
|
|
125
|
-
if (current.size !== this._lastFrontier.size) {
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
for (const [writerId, tipSha] of current) {
|
|
130
|
-
if (this._lastFrontier.get(writerId) !== tipSha) {
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Returns a lightweight status snapshot of the graph's operational state.
|
|
140
|
-
*
|
|
141
|
-
* This method is O(writers) and does NOT trigger materialization.
|
|
142
|
-
*
|
|
143
|
-
* @this {import('../WarpGraph.js').default}
|
|
144
|
-
* @returns {Promise<{
|
|
145
|
-
* cachedState: 'fresh' | 'stale' | 'none',
|
|
146
|
-
* patchesSinceCheckpoint: number,
|
|
147
|
-
* tombstoneRatio: number,
|
|
148
|
-
* writers: number,
|
|
149
|
-
* frontier: Record<string, string>,
|
|
150
|
-
* }>} The graph status
|
|
151
|
-
* @throws {Error} If listing refs fails
|
|
152
|
-
*/
|
|
153
|
-
export async function status() {
|
|
154
|
-
// Fetch frontier once, reuse for both staleness check and return value
|
|
155
|
-
const frontier = await this.getFrontier();
|
|
156
|
-
|
|
157
|
-
// Determine cachedState
|
|
158
|
-
/** @type {'fresh' | 'stale' | 'none'} */
|
|
159
|
-
let cachedState;
|
|
160
|
-
if (this._cachedState === null) {
|
|
161
|
-
cachedState = 'none';
|
|
162
|
-
} else if (this._stateDirty || !this._lastFrontier ||
|
|
163
|
-
frontier.size !== this._lastFrontier.size ||
|
|
164
|
-
![...frontier].every(([w, sha]) => /** @type {Map<string, string>} */ (this._lastFrontier).get(w) === sha)) {
|
|
165
|
-
cachedState = 'stale';
|
|
166
|
-
} else {
|
|
167
|
-
cachedState = 'fresh';
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// patchesSinceCheckpoint
|
|
171
|
-
const patchesSinceCheckpoint = this._patchesSinceCheckpoint;
|
|
172
|
-
|
|
173
|
-
// tombstoneRatio
|
|
174
|
-
let tombstoneRatio = 0;
|
|
175
|
-
if (this._cachedState) {
|
|
176
|
-
const metrics = collectGCMetrics(this._cachedState);
|
|
177
|
-
tombstoneRatio = metrics.tombstoneRatio;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// writers
|
|
181
|
-
const writers = frontier.size;
|
|
182
|
-
|
|
183
|
-
// Convert frontier Map to plain object
|
|
184
|
-
const frontierObj = Object.fromEntries(frontier);
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
cachedState,
|
|
188
|
-
patchesSinceCheckpoint,
|
|
189
|
-
tombstoneRatio,
|
|
190
|
-
writers,
|
|
191
|
-
frontier: frontierObj,
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Creates a sync request to send to a remote peer.
|
|
197
|
-
* The request contains the local frontier for comparison.
|
|
198
|
-
*
|
|
199
|
-
* @this {import('../WarpGraph.js').default}
|
|
200
|
-
* @returns {Promise<import('../services/SyncProtocol.js').SyncRequest>} The sync request
|
|
201
|
-
* @throws {Error} If listing refs fails
|
|
202
|
-
*
|
|
203
|
-
* @example
|
|
204
|
-
* const request = await graph.createSyncRequest();
|
|
205
|
-
* // Send request to remote peer...
|
|
206
|
-
*/
|
|
207
|
-
export async function createSyncRequest() {
|
|
208
|
-
const frontier = await this.getFrontier();
|
|
209
|
-
return createSyncRequestImpl(frontier);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Processes an incoming sync request and returns patches the requester needs.
|
|
214
|
-
*
|
|
215
|
-
* @this {import('../WarpGraph.js').default}
|
|
216
|
-
* @param {import('../services/SyncProtocol.js').SyncRequest} request - The incoming sync request
|
|
217
|
-
* @returns {Promise<import('../services/SyncProtocol.js').SyncResponse>} The sync response
|
|
218
|
-
* @throws {Error} If listing refs or reading patches fails
|
|
219
|
-
*
|
|
220
|
-
* @example
|
|
221
|
-
* // Receive request from remote peer
|
|
222
|
-
* const response = await graph.processSyncRequest(request);
|
|
223
|
-
* // Send response back to requester...
|
|
224
|
-
*/
|
|
225
|
-
export async function processSyncRequest(request) {
|
|
226
|
-
const localFrontier = await this.getFrontier();
|
|
227
|
-
/** @type {CorePersistence} */
|
|
228
|
-
const persistence = this._persistence;
|
|
229
|
-
return await processSyncRequestImpl(
|
|
230
|
-
request,
|
|
231
|
-
localFrontier,
|
|
232
|
-
persistence,
|
|
233
|
-
this._graphName,
|
|
234
|
-
{ codec: this._codec }
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Applies a sync response to the local graph state.
|
|
240
|
-
* Updates the cached state with received patches.
|
|
241
|
-
*
|
|
242
|
-
* **Requires a cached state.**
|
|
243
|
-
*
|
|
244
|
-
* @this {import('../WarpGraph.js').default}
|
|
245
|
-
* @param {import('../services/SyncProtocol.js').SyncResponse} response - The sync response
|
|
246
|
-
* @returns {{state: import('../services/JoinReducer.js').WarpStateV5, applied: number}} Result with updated state
|
|
247
|
-
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
248
|
-
*
|
|
249
|
-
* @example
|
|
250
|
-
* await graph.materialize(); // Cache state first
|
|
251
|
-
* const result = graph.applySyncResponse(response);
|
|
252
|
-
* console.log(`Applied ${result.applied} patches from remote`);
|
|
253
|
-
*/
|
|
254
|
-
export function applySyncResponse(response) {
|
|
255
|
-
if (!this._cachedState) {
|
|
256
|
-
throw new QueryError(E_NO_STATE_MSG, {
|
|
257
|
-
code: 'E_NO_STATE',
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const currentFrontier = /** @type {Map<string, string>} */ (/** @type {unknown} */ (this._cachedState.observedFrontier));
|
|
262
|
-
const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._cachedState, currentFrontier));
|
|
263
|
-
|
|
264
|
-
// Update cached state
|
|
265
|
-
this._cachedState = result.state;
|
|
266
|
-
|
|
267
|
-
// Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale.
|
|
268
|
-
// Merge the response's per-writer tips into the stored frontier snapshot.
|
|
269
|
-
if (this._lastFrontier && Array.isArray(response.patches)) {
|
|
270
|
-
for (const { writerId, sha } of response.patches) {
|
|
271
|
-
if (writerId && sha) {
|
|
272
|
-
this._lastFrontier.set(writerId, sha);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Track patches for GC
|
|
278
|
-
this._patchesSinceGC += result.applied;
|
|
279
|
-
|
|
280
|
-
// State is now in sync with the frontier — clear dirty flag
|
|
281
|
-
this._stateDirty = false;
|
|
282
|
-
|
|
283
|
-
return result;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Checks if sync is needed with a remote frontier.
|
|
288
|
-
*
|
|
289
|
-
* @this {import('../WarpGraph.js').default}
|
|
290
|
-
* @param {Map<string, string>} remoteFrontier - The remote peer's frontier
|
|
291
|
-
* @returns {Promise<boolean>} True if sync would transfer any patches
|
|
292
|
-
* @throws {Error} If listing refs fails
|
|
293
|
-
*/
|
|
294
|
-
export async function syncNeeded(remoteFrontier) {
|
|
295
|
-
const localFrontier = await this.getFrontier();
|
|
296
|
-
return syncNeededImpl(localFrontier, remoteFrontier);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* Syncs with a remote peer (HTTP or direct graph instance).
|
|
301
|
-
*
|
|
302
|
-
* @this {import('../WarpGraph.js').default}
|
|
303
|
-
* @param {string|import('../WarpGraph.js').default} remote - URL or peer graph instance
|
|
304
|
-
* @param {Object} [options]
|
|
305
|
-
* @param {string} [options.path='/sync'] - Sync path (HTTP mode)
|
|
306
|
-
* @param {number} [options.retries=3] - Retry count
|
|
307
|
-
* @param {number} [options.baseDelayMs=250] - Base backoff delay
|
|
308
|
-
* @param {number} [options.maxDelayMs=2000] - Max backoff delay
|
|
309
|
-
* @param {number} [options.timeoutMs=10000] - Request timeout
|
|
310
|
-
* @param {AbortSignal} [options.signal] - Abort signal
|
|
311
|
-
* @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
|
|
312
|
-
* @param {boolean} [options.materialize=false] - Auto-materialize after sync
|
|
313
|
-
* @param {{ secret: string, keyId?: string }} [options.auth] - Client auth credentials
|
|
314
|
-
* @returns {Promise<{applied: number, attempts: number, state?: import('../services/JoinReducer.js').WarpStateV5}>}
|
|
315
|
-
*/
|
|
316
|
-
export async function syncWith(remote, options = {}) {
|
|
317
|
-
const t0 = this._clock.now();
|
|
318
|
-
const {
|
|
319
|
-
path = '/sync',
|
|
320
|
-
retries = DEFAULT_SYNC_WITH_RETRIES,
|
|
321
|
-
baseDelayMs = DEFAULT_SYNC_WITH_BASE_DELAY_MS,
|
|
322
|
-
maxDelayMs = DEFAULT_SYNC_WITH_MAX_DELAY_MS,
|
|
323
|
-
timeoutMs = DEFAULT_SYNC_WITH_TIMEOUT_MS,
|
|
324
|
-
signal,
|
|
325
|
-
onStatus,
|
|
326
|
-
materialize: materializeAfterSync = false,
|
|
327
|
-
auth,
|
|
328
|
-
} = options;
|
|
329
|
-
|
|
330
|
-
const hasPathOverride = Object.prototype.hasOwnProperty.call(options, 'path');
|
|
331
|
-
const isDirectPeer = remote && typeof remote === 'object' &&
|
|
332
|
-
typeof remote.processSyncRequest === 'function';
|
|
333
|
-
let targetUrl = null;
|
|
334
|
-
if (!isDirectPeer) {
|
|
335
|
-
try {
|
|
336
|
-
targetUrl = remote instanceof URL ? new URL(remote.toString()) : new URL(/** @type {string} */ (remote));
|
|
337
|
-
} catch {
|
|
338
|
-
throw new SyncError('Invalid remote URL', {
|
|
339
|
-
code: 'E_SYNC_REMOTE_URL',
|
|
340
|
-
context: { remote },
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
if (!['http:', 'https:'].includes(targetUrl.protocol)) {
|
|
345
|
-
throw new SyncError('Unsupported remote URL protocol', {
|
|
346
|
-
code: 'E_SYNC_REMOTE_URL',
|
|
347
|
-
context: { protocol: targetUrl.protocol },
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const normalizedPath = normalizeSyncPath(path);
|
|
352
|
-
if (!targetUrl.pathname || targetUrl.pathname === '/') {
|
|
353
|
-
targetUrl.pathname = normalizedPath;
|
|
354
|
-
} else if (hasPathOverride) {
|
|
355
|
-
targetUrl.pathname = normalizedPath;
|
|
356
|
-
}
|
|
357
|
-
targetUrl.hash = '';
|
|
358
|
-
}
|
|
359
|
-
let attempt = 0;
|
|
360
|
-
const emit = (/** @type {string} */ type, /** @type {Record<string, unknown>} */ payload = {}) => {
|
|
361
|
-
if (typeof onStatus === 'function') {
|
|
362
|
-
onStatus(/** @type {{type: string, attempt: number}} */ ({ type, attempt, ...payload }));
|
|
363
|
-
}
|
|
364
|
-
};
|
|
365
|
-
const shouldRetry = (/** @type {unknown} */ err) => {
|
|
366
|
-
if (isDirectPeer) { return false; }
|
|
367
|
-
if (err instanceof SyncError) {
|
|
368
|
-
return ['E_SYNC_REMOTE', 'E_SYNC_TIMEOUT', 'E_SYNC_NETWORK'].includes(err.code);
|
|
369
|
-
}
|
|
370
|
-
return err instanceof TimeoutError;
|
|
371
|
-
};
|
|
372
|
-
const executeAttempt = async () => {
|
|
373
|
-
checkAborted(signal, 'syncWith');
|
|
374
|
-
attempt += 1;
|
|
375
|
-
const attemptStart = this._clock.now();
|
|
376
|
-
emit('connecting');
|
|
377
|
-
const request = await this.createSyncRequest();
|
|
378
|
-
emit('requestBuilt');
|
|
379
|
-
let response;
|
|
380
|
-
if (isDirectPeer) {
|
|
381
|
-
emit('requestSent');
|
|
382
|
-
response = await remote.processSyncRequest(request);
|
|
383
|
-
emit('responseReceived');
|
|
384
|
-
} else {
|
|
385
|
-
emit('requestSent');
|
|
386
|
-
const bodyStr = JSON.stringify(request);
|
|
387
|
-
const authHeaders = await buildSyncAuthHeaders({
|
|
388
|
-
auth, bodyStr, targetUrl: /** @type {URL} */ (targetUrl), crypto: this._crypto,
|
|
389
|
-
});
|
|
390
|
-
let res;
|
|
391
|
-
try {
|
|
392
|
-
res = await timeout(timeoutMs, (timeoutSignal) => {
|
|
393
|
-
const combinedSignal = signal
|
|
394
|
-
? AbortSignal.any([timeoutSignal, signal])
|
|
395
|
-
: timeoutSignal;
|
|
396
|
-
return fetch(/** @type {URL} */ (targetUrl).toString(), {
|
|
397
|
-
method: 'POST',
|
|
398
|
-
headers: {
|
|
399
|
-
'content-type': 'application/json',
|
|
400
|
-
'accept': 'application/json',
|
|
401
|
-
...authHeaders,
|
|
402
|
-
},
|
|
403
|
-
body: bodyStr,
|
|
404
|
-
signal: combinedSignal,
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
} catch (err) {
|
|
408
|
-
if (isError(err) && err.name === 'AbortError') {
|
|
409
|
-
throw new OperationAbortedError('syncWith', { reason: 'Signal received' });
|
|
410
|
-
}
|
|
411
|
-
if (err instanceof TimeoutError) {
|
|
412
|
-
throw new SyncError('Sync request timed out', {
|
|
413
|
-
code: 'E_SYNC_TIMEOUT',
|
|
414
|
-
context: { timeoutMs },
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
throw new SyncError('Network error', {
|
|
418
|
-
code: 'E_SYNC_NETWORK',
|
|
419
|
-
context: { message: isError(err) ? err.message : String(err) },
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
emit('responseReceived', { status: res.status });
|
|
424
|
-
|
|
425
|
-
if (res.status >= 500) {
|
|
426
|
-
throw new SyncError(`Remote error: ${res.status}`, {
|
|
427
|
-
code: 'E_SYNC_REMOTE',
|
|
428
|
-
context: { status: res.status },
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
if (res.status >= 400) {
|
|
433
|
-
throw new SyncError(`Protocol error: ${res.status}`, {
|
|
434
|
-
code: 'E_SYNC_PROTOCOL',
|
|
435
|
-
context: { status: res.status },
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
try {
|
|
440
|
-
response = await res.json();
|
|
441
|
-
} catch {
|
|
442
|
-
throw new SyncError('Invalid JSON response', {
|
|
443
|
-
code: 'E_SYNC_PROTOCOL',
|
|
444
|
-
context: { status: res.status },
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (!this._cachedState) {
|
|
450
|
-
await this.materialize();
|
|
451
|
-
emit('materialized');
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (!response || typeof response !== 'object' ||
|
|
455
|
-
response.type !== 'sync-response' ||
|
|
456
|
-
!response.frontier || typeof response.frontier !== 'object' || Array.isArray(response.frontier) ||
|
|
457
|
-
!Array.isArray(response.patches)) {
|
|
458
|
-
throw new SyncError('Invalid sync response', {
|
|
459
|
-
code: 'E_SYNC_PROTOCOL',
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
const result = this.applySyncResponse(response);
|
|
464
|
-
emit('applied', { applied: result.applied });
|
|
465
|
-
|
|
466
|
-
const durationMs = this._clock.now() - attemptStart;
|
|
467
|
-
emit('complete', { durationMs, applied: result.applied });
|
|
468
|
-
return { applied: result.applied, attempts: attempt };
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
const syncResult = await retry(executeAttempt, {
|
|
473
|
-
retries,
|
|
474
|
-
delay: baseDelayMs,
|
|
475
|
-
maxDelay: maxDelayMs,
|
|
476
|
-
backoff: 'exponential',
|
|
477
|
-
jitter: 'decorrelated',
|
|
478
|
-
signal,
|
|
479
|
-
shouldRetry,
|
|
480
|
-
onRetry: (/** @type {Error} */ error, /** @type {number} */ attemptNumber, /** @type {number} */ delayMs) => {
|
|
481
|
-
if (typeof onStatus === 'function') {
|
|
482
|
-
onStatus(/** @type {{type: string, attempt: number, delayMs: number, error: Error}} */ ({ type: 'retrying', attempt: attemptNumber, delayMs, error }));
|
|
483
|
-
}
|
|
484
|
-
},
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
this._logTiming('syncWith', t0, { metrics: `${syncResult.applied} patches applied` });
|
|
488
|
-
|
|
489
|
-
if (materializeAfterSync) {
|
|
490
|
-
if (!this._cachedState) { await this.materialize(); }
|
|
491
|
-
return { ...syncResult, state: /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (this._cachedState) };
|
|
492
|
-
}
|
|
493
|
-
return syncResult;
|
|
494
|
-
} catch (err) {
|
|
495
|
-
this._logTiming('syncWith', t0, { error: /** @type {Error} */ (err) });
|
|
496
|
-
if (isError(err) && err.name === 'AbortError') {
|
|
497
|
-
const abortedError = new OperationAbortedError('syncWith', { reason: 'Signal received' });
|
|
498
|
-
if (typeof onStatus === 'function') {
|
|
499
|
-
onStatus({ type: 'failed', attempt, error: abortedError });
|
|
500
|
-
}
|
|
501
|
-
throw abortedError;
|
|
502
|
-
}
|
|
503
|
-
if (err instanceof RetryExhaustedError) {
|
|
504
|
-
const cause = /** @type {Error} */ (err.cause || err);
|
|
505
|
-
if (typeof onStatus === 'function') {
|
|
506
|
-
onStatus({ type: 'failed', attempt: err.attempts, error: cause });
|
|
507
|
-
}
|
|
508
|
-
throw cause;
|
|
509
|
-
}
|
|
510
|
-
if (typeof onStatus === 'function') {
|
|
511
|
-
onStatus({ type: 'failed', attempt, error: /** @type {Error} */ (err) });
|
|
512
|
-
}
|
|
513
|
-
throw err;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Starts a built-in sync server for this graph.
|
|
519
|
-
*
|
|
520
|
-
* @this {import('../WarpGraph.js').default}
|
|
521
|
-
* @param {Object} options
|
|
522
|
-
* @param {number} options.port - Port to listen on
|
|
523
|
-
* @param {string} [options.host='127.0.0.1'] - Host to bind
|
|
524
|
-
* @param {string} [options.path='/sync'] - Path to handle sync requests
|
|
525
|
-
* @param {number} [options.maxRequestBytes=4194304] - Max request size in bytes
|
|
526
|
-
* @param {import('../../ports/HttpServerPort.js').default} options.httpPort - HTTP server adapter
|
|
527
|
-
* @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only' }} [options.auth] - Auth configuration
|
|
528
|
-
* @returns {Promise<{close: () => Promise<void>, url: string}>} Server handle
|
|
529
|
-
* @throws {Error} If port is not a number
|
|
530
|
-
* @throws {Error} If httpPort adapter is not provided
|
|
531
|
-
*/
|
|
532
|
-
export async function serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort, auth } = /** @type {{ port: number, httpPort: import('../../ports/HttpServerPort.js').default }} */ ({})) {
|
|
533
|
-
if (typeof port !== 'number') {
|
|
534
|
-
throw new Error('serve() requires a numeric port');
|
|
535
|
-
}
|
|
536
|
-
if (!httpPort) {
|
|
537
|
-
throw new Error('serve() requires an httpPort adapter');
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const authConfig = auth
|
|
541
|
-
? { ...auth, crypto: this._crypto, logger: this._logger || undefined }
|
|
542
|
-
: undefined;
|
|
543
|
-
|
|
544
|
-
const httpServer = new HttpSyncServer({
|
|
545
|
-
httpPort,
|
|
546
|
-
graph: this,
|
|
547
|
-
path,
|
|
548
|
-
host,
|
|
549
|
-
maxRequestBytes,
|
|
550
|
-
auth: authConfig,
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
return await httpServer.listen(port);
|
|
554
|
-
}
|