@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.
@@ -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
- return await stream.collect({ asString: false });
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
- }