@git-stunts/git-warp 10.7.0 → 11.2.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 +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +214 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +543 -0
- package/bin/warp-graph.js +19 -2824
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +9 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/hooks/post-merge.sh +0 -60
|
@@ -0,0 +1,549 @@
|
|
|
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
|
+
|
|
35
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes a sync endpoint path to ensure it starts with '/'.
|
|
39
|
+
* Returns '/sync' if no path is provided.
|
|
40
|
+
*
|
|
41
|
+
* @param {string|undefined|null} path - The sync path to normalize
|
|
42
|
+
* @returns {string} Normalized path starting with '/'
|
|
43
|
+
* @private
|
|
44
|
+
*/
|
|
45
|
+
function normalizeSyncPath(path) {
|
|
46
|
+
if (!path) {
|
|
47
|
+
return '/sync';
|
|
48
|
+
}
|
|
49
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Builds auth headers for an outgoing sync request if auth is configured.
|
|
54
|
+
*
|
|
55
|
+
* @param {Object} params
|
|
56
|
+
* @param {{ secret: string, keyId?: string }|undefined} params.auth
|
|
57
|
+
* @param {string} params.bodyStr - Serialized request body
|
|
58
|
+
* @param {URL} params.targetUrl
|
|
59
|
+
* @param {import('../../ports/CryptoPort.js').default} params.crypto
|
|
60
|
+
* @returns {Promise<Record<string, string>>}
|
|
61
|
+
* @private
|
|
62
|
+
*/
|
|
63
|
+
async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
|
|
64
|
+
if (!auth || !auth.secret) {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
const bodyBuf = new TextEncoder().encode(bodyStr);
|
|
68
|
+
return await signSyncRequest(
|
|
69
|
+
{
|
|
70
|
+
method: 'POST',
|
|
71
|
+
path: canonicalizePath(targetUrl.pathname + (targetUrl.search || '')),
|
|
72
|
+
contentType: 'application/json',
|
|
73
|
+
body: bodyBuf,
|
|
74
|
+
secret: auth.secret,
|
|
75
|
+
keyId: auth.keyId || 'default',
|
|
76
|
+
},
|
|
77
|
+
{ crypto },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Exported methods ────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Returns the current frontier — a Map of writerId → tip SHA.
|
|
85
|
+
*
|
|
86
|
+
* @this {import('../WarpGraph.js').default}
|
|
87
|
+
* @returns {Promise<Map<string, string>>} Frontier map
|
|
88
|
+
* @throws {Error} If listing refs fails
|
|
89
|
+
*/
|
|
90
|
+
export async function getFrontier() {
|
|
91
|
+
const writerIds = await this.discoverWriters();
|
|
92
|
+
const frontier = createFrontier();
|
|
93
|
+
|
|
94
|
+
for (const writerId of writerIds) {
|
|
95
|
+
const writerRef = buildWriterRef(this._graphName, writerId);
|
|
96
|
+
const tipSha = await this._persistence.readRef(writerRef);
|
|
97
|
+
if (tipSha) {
|
|
98
|
+
updateFrontier(frontier, writerId, tipSha);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return frontier;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Checks whether any writer tip has changed since the last materialize.
|
|
107
|
+
*
|
|
108
|
+
* O(writers) comparison of stored writer tip SHAs against current refs.
|
|
109
|
+
* Cheap "has anything changed?" check without materialization.
|
|
110
|
+
*
|
|
111
|
+
* @this {import('../WarpGraph.js').default}
|
|
112
|
+
* @returns {Promise<boolean>} True if frontier has changed (or never materialized)
|
|
113
|
+
* @throws {Error} If listing refs fails
|
|
114
|
+
*/
|
|
115
|
+
export async function hasFrontierChanged() {
|
|
116
|
+
if (this._lastFrontier === null) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const current = await this.getFrontier();
|
|
121
|
+
|
|
122
|
+
if (current.size !== this._lastFrontier.size) {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const [writerId, tipSha] of current) {
|
|
127
|
+
if (this._lastFrontier.get(writerId) !== tipSha) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Returns a lightweight status snapshot of the graph's operational state.
|
|
137
|
+
*
|
|
138
|
+
* This method is O(writers) and does NOT trigger materialization.
|
|
139
|
+
*
|
|
140
|
+
* @this {import('../WarpGraph.js').default}
|
|
141
|
+
* @returns {Promise<{
|
|
142
|
+
* cachedState: 'fresh' | 'stale' | 'none',
|
|
143
|
+
* patchesSinceCheckpoint: number,
|
|
144
|
+
* tombstoneRatio: number,
|
|
145
|
+
* writers: number,
|
|
146
|
+
* frontier: Record<string, string>,
|
|
147
|
+
* }>} The graph status
|
|
148
|
+
* @throws {Error} If listing refs fails
|
|
149
|
+
*/
|
|
150
|
+
export async function status() {
|
|
151
|
+
// Fetch frontier once, reuse for both staleness check and return value
|
|
152
|
+
const frontier = await this.getFrontier();
|
|
153
|
+
|
|
154
|
+
// Determine cachedState
|
|
155
|
+
/** @type {'fresh' | 'stale' | 'none'} */
|
|
156
|
+
let cachedState;
|
|
157
|
+
if (this._cachedState === null) {
|
|
158
|
+
cachedState = 'none';
|
|
159
|
+
} else if (this._stateDirty || !this._lastFrontier ||
|
|
160
|
+
frontier.size !== this._lastFrontier.size ||
|
|
161
|
+
![...frontier].every(([w, sha]) => /** @type {Map<string, string>} */ (this._lastFrontier).get(w) === sha)) {
|
|
162
|
+
cachedState = 'stale';
|
|
163
|
+
} else {
|
|
164
|
+
cachedState = 'fresh';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// patchesSinceCheckpoint
|
|
168
|
+
const patchesSinceCheckpoint = this._patchesSinceCheckpoint;
|
|
169
|
+
|
|
170
|
+
// tombstoneRatio
|
|
171
|
+
let tombstoneRatio = 0;
|
|
172
|
+
if (this._cachedState) {
|
|
173
|
+
const metrics = collectGCMetrics(this._cachedState);
|
|
174
|
+
tombstoneRatio = metrics.tombstoneRatio;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// writers
|
|
178
|
+
const writers = frontier.size;
|
|
179
|
+
|
|
180
|
+
// Convert frontier Map to plain object
|
|
181
|
+
const frontierObj = Object.fromEntries(frontier);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
cachedState,
|
|
185
|
+
patchesSinceCheckpoint,
|
|
186
|
+
tombstoneRatio,
|
|
187
|
+
writers,
|
|
188
|
+
frontier: frontierObj,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Creates a sync request to send to a remote peer.
|
|
194
|
+
* The request contains the local frontier for comparison.
|
|
195
|
+
*
|
|
196
|
+
* @this {import('../WarpGraph.js').default}
|
|
197
|
+
* @returns {Promise<import('../services/SyncProtocol.js').SyncRequest>} The sync request
|
|
198
|
+
* @throws {Error} If listing refs fails
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* const request = await graph.createSyncRequest();
|
|
202
|
+
* // Send request to remote peer...
|
|
203
|
+
*/
|
|
204
|
+
export async function createSyncRequest() {
|
|
205
|
+
const frontier = await this.getFrontier();
|
|
206
|
+
return createSyncRequestImpl(frontier);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Processes an incoming sync request and returns patches the requester needs.
|
|
211
|
+
*
|
|
212
|
+
* @this {import('../WarpGraph.js').default}
|
|
213
|
+
* @param {import('../services/SyncProtocol.js').SyncRequest} request - The incoming sync request
|
|
214
|
+
* @returns {Promise<import('../services/SyncProtocol.js').SyncResponse>} The sync response
|
|
215
|
+
* @throws {Error} If listing refs or reading patches fails
|
|
216
|
+
*
|
|
217
|
+
* @example
|
|
218
|
+
* // Receive request from remote peer
|
|
219
|
+
* const response = await graph.processSyncRequest(request);
|
|
220
|
+
* // Send response back to requester...
|
|
221
|
+
*/
|
|
222
|
+
export async function processSyncRequest(request) {
|
|
223
|
+
const localFrontier = await this.getFrontier();
|
|
224
|
+
return await processSyncRequestImpl(
|
|
225
|
+
request,
|
|
226
|
+
localFrontier,
|
|
227
|
+
/** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type
|
|
228
|
+
this._graphName,
|
|
229
|
+
{ codec: this._codec }
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Applies a sync response to the local graph state.
|
|
235
|
+
* Updates the cached state with received patches.
|
|
236
|
+
*
|
|
237
|
+
* **Requires a cached state.**
|
|
238
|
+
*
|
|
239
|
+
* @this {import('../WarpGraph.js').default}
|
|
240
|
+
* @param {import('../services/SyncProtocol.js').SyncResponse} response - The sync response
|
|
241
|
+
* @returns {{state: import('../services/JoinReducer.js').WarpStateV5, applied: number}} Result with updated state
|
|
242
|
+
* @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* await graph.materialize(); // Cache state first
|
|
246
|
+
* const result = graph.applySyncResponse(response);
|
|
247
|
+
* console.log(`Applied ${result.applied} patches from remote`);
|
|
248
|
+
*/
|
|
249
|
+
export function applySyncResponse(response) {
|
|
250
|
+
if (!this._cachedState) {
|
|
251
|
+
throw new QueryError(E_NO_STATE_MSG, {
|
|
252
|
+
code: 'E_NO_STATE',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const currentFrontier = /** @type {any} */ (this._cachedState.observedFrontier); // TODO(ts-cleanup): narrow port type
|
|
257
|
+
const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._cachedState, currentFrontier));
|
|
258
|
+
|
|
259
|
+
// Update cached state
|
|
260
|
+
this._cachedState = result.state;
|
|
261
|
+
|
|
262
|
+
// Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale.
|
|
263
|
+
// Merge the response's per-writer tips into the stored frontier snapshot.
|
|
264
|
+
if (this._lastFrontier && Array.isArray(response.patches)) {
|
|
265
|
+
for (const { writerId, sha } of response.patches) {
|
|
266
|
+
if (writerId && sha) {
|
|
267
|
+
this._lastFrontier.set(writerId, sha);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Track patches for GC
|
|
273
|
+
this._patchesSinceGC += result.applied;
|
|
274
|
+
|
|
275
|
+
// State is now in sync with the frontier — clear dirty flag
|
|
276
|
+
this._stateDirty = false;
|
|
277
|
+
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Checks if sync is needed with a remote frontier.
|
|
283
|
+
*
|
|
284
|
+
* @this {import('../WarpGraph.js').default}
|
|
285
|
+
* @param {Map<string, string>} remoteFrontier - The remote peer's frontier
|
|
286
|
+
* @returns {Promise<boolean>} True if sync would transfer any patches
|
|
287
|
+
* @throws {Error} If listing refs fails
|
|
288
|
+
*/
|
|
289
|
+
export async function syncNeeded(remoteFrontier) {
|
|
290
|
+
const localFrontier = await this.getFrontier();
|
|
291
|
+
return syncNeededImpl(localFrontier, remoteFrontier);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Syncs with a remote peer (HTTP or direct graph instance).
|
|
296
|
+
*
|
|
297
|
+
* @this {import('../WarpGraph.js').default}
|
|
298
|
+
* @param {string|import('../WarpGraph.js').default} remote - URL or peer graph instance
|
|
299
|
+
* @param {Object} [options]
|
|
300
|
+
* @param {string} [options.path='/sync'] - Sync path (HTTP mode)
|
|
301
|
+
* @param {number} [options.retries=3] - Retry count
|
|
302
|
+
* @param {number} [options.baseDelayMs=250] - Base backoff delay
|
|
303
|
+
* @param {number} [options.maxDelayMs=2000] - Max backoff delay
|
|
304
|
+
* @param {number} [options.timeoutMs=10000] - Request timeout
|
|
305
|
+
* @param {AbortSignal} [options.signal] - Abort signal
|
|
306
|
+
* @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
|
|
307
|
+
* @param {boolean} [options.materialize=false] - Auto-materialize after sync
|
|
308
|
+
* @param {{ secret: string, keyId?: string }} [options.auth] - Client auth credentials
|
|
309
|
+
* @returns {Promise<{applied: number, attempts: number, state?: import('../services/JoinReducer.js').WarpStateV5}>}
|
|
310
|
+
*/
|
|
311
|
+
export async function syncWith(remote, options = {}) {
|
|
312
|
+
const t0 = this._clock.now();
|
|
313
|
+
const {
|
|
314
|
+
path = '/sync',
|
|
315
|
+
retries = DEFAULT_SYNC_WITH_RETRIES,
|
|
316
|
+
baseDelayMs = DEFAULT_SYNC_WITH_BASE_DELAY_MS,
|
|
317
|
+
maxDelayMs = DEFAULT_SYNC_WITH_MAX_DELAY_MS,
|
|
318
|
+
timeoutMs = DEFAULT_SYNC_WITH_TIMEOUT_MS,
|
|
319
|
+
signal,
|
|
320
|
+
onStatus,
|
|
321
|
+
materialize: materializeAfterSync = false,
|
|
322
|
+
auth,
|
|
323
|
+
} = options;
|
|
324
|
+
|
|
325
|
+
const hasPathOverride = Object.prototype.hasOwnProperty.call(options, 'path');
|
|
326
|
+
const isDirectPeer = remote && typeof remote === 'object' &&
|
|
327
|
+
typeof remote.processSyncRequest === 'function';
|
|
328
|
+
let targetUrl = null;
|
|
329
|
+
if (!isDirectPeer) {
|
|
330
|
+
try {
|
|
331
|
+
targetUrl = remote instanceof URL ? new URL(remote.toString()) : new URL(/** @type {string} */ (remote));
|
|
332
|
+
} catch {
|
|
333
|
+
throw new SyncError('Invalid remote URL', {
|
|
334
|
+
code: 'E_SYNC_REMOTE_URL',
|
|
335
|
+
context: { remote },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!['http:', 'https:'].includes(targetUrl.protocol)) {
|
|
340
|
+
throw new SyncError('Unsupported remote URL protocol', {
|
|
341
|
+
code: 'E_SYNC_REMOTE_URL',
|
|
342
|
+
context: { protocol: targetUrl.protocol },
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const normalizedPath = normalizeSyncPath(path);
|
|
347
|
+
if (!targetUrl.pathname || targetUrl.pathname === '/') {
|
|
348
|
+
targetUrl.pathname = normalizedPath;
|
|
349
|
+
} else if (hasPathOverride) {
|
|
350
|
+
targetUrl.pathname = normalizedPath;
|
|
351
|
+
}
|
|
352
|
+
targetUrl.hash = '';
|
|
353
|
+
}
|
|
354
|
+
let attempt = 0;
|
|
355
|
+
const emit = (/** @type {string} */ type, /** @type {Record<string, any>} */ payload = {}) => {
|
|
356
|
+
if (typeof onStatus === 'function') {
|
|
357
|
+
onStatus(/** @type {any} */ ({ type, attempt, ...payload })); // TODO(ts-cleanup): type sync protocol
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const shouldRetry = (/** @type {any} */ err) => { // TODO(ts-cleanup): type error
|
|
361
|
+
if (isDirectPeer) { return false; }
|
|
362
|
+
if (err instanceof SyncError) {
|
|
363
|
+
return ['E_SYNC_REMOTE', 'E_SYNC_TIMEOUT', 'E_SYNC_NETWORK'].includes(err.code);
|
|
364
|
+
}
|
|
365
|
+
return err instanceof TimeoutError;
|
|
366
|
+
};
|
|
367
|
+
const executeAttempt = async () => {
|
|
368
|
+
checkAborted(signal, 'syncWith');
|
|
369
|
+
attempt += 1;
|
|
370
|
+
const attemptStart = this._clock.now();
|
|
371
|
+
emit('connecting');
|
|
372
|
+
const request = await this.createSyncRequest();
|
|
373
|
+
emit('requestBuilt');
|
|
374
|
+
let response;
|
|
375
|
+
if (isDirectPeer) {
|
|
376
|
+
emit('requestSent');
|
|
377
|
+
response = await remote.processSyncRequest(request);
|
|
378
|
+
emit('responseReceived');
|
|
379
|
+
} else {
|
|
380
|
+
emit('requestSent');
|
|
381
|
+
const bodyStr = JSON.stringify(request);
|
|
382
|
+
const authHeaders = await buildSyncAuthHeaders({
|
|
383
|
+
auth, bodyStr, targetUrl: /** @type {URL} */ (targetUrl), crypto: this._crypto,
|
|
384
|
+
});
|
|
385
|
+
let res;
|
|
386
|
+
try {
|
|
387
|
+
res = await timeout(timeoutMs, (timeoutSignal) => {
|
|
388
|
+
const combinedSignal = signal
|
|
389
|
+
? AbortSignal.any([timeoutSignal, signal])
|
|
390
|
+
: timeoutSignal;
|
|
391
|
+
return fetch(/** @type {URL} */ (targetUrl).toString(), {
|
|
392
|
+
method: 'POST',
|
|
393
|
+
headers: {
|
|
394
|
+
'content-type': 'application/json',
|
|
395
|
+
'accept': 'application/json',
|
|
396
|
+
...authHeaders,
|
|
397
|
+
},
|
|
398
|
+
body: bodyStr,
|
|
399
|
+
signal: combinedSignal,
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
} catch (err) {
|
|
403
|
+
if (/** @type {any} */ (err)?.name === 'AbortError') { // TODO(ts-cleanup): type error
|
|
404
|
+
throw new OperationAbortedError('syncWith', { reason: 'Signal received' });
|
|
405
|
+
}
|
|
406
|
+
if (err instanceof TimeoutError) {
|
|
407
|
+
throw new SyncError('Sync request timed out', {
|
|
408
|
+
code: 'E_SYNC_TIMEOUT',
|
|
409
|
+
context: { timeoutMs },
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
throw new SyncError('Network error', {
|
|
413
|
+
code: 'E_SYNC_NETWORK',
|
|
414
|
+
context: { message: /** @type {any} */ (err)?.message }, // TODO(ts-cleanup): type error
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
emit('responseReceived', { status: res.status });
|
|
419
|
+
|
|
420
|
+
if (res.status >= 500) {
|
|
421
|
+
throw new SyncError(`Remote error: ${res.status}`, {
|
|
422
|
+
code: 'E_SYNC_REMOTE',
|
|
423
|
+
context: { status: res.status },
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (res.status >= 400) {
|
|
428
|
+
throw new SyncError(`Protocol error: ${res.status}`, {
|
|
429
|
+
code: 'E_SYNC_PROTOCOL',
|
|
430
|
+
context: { status: res.status },
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
try {
|
|
435
|
+
response = await res.json();
|
|
436
|
+
} catch {
|
|
437
|
+
throw new SyncError('Invalid JSON response', {
|
|
438
|
+
code: 'E_SYNC_PROTOCOL',
|
|
439
|
+
context: { status: res.status },
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (!this._cachedState) {
|
|
445
|
+
await this.materialize();
|
|
446
|
+
emit('materialized');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!response || typeof response !== 'object' ||
|
|
450
|
+
response.type !== 'sync-response' ||
|
|
451
|
+
!response.frontier || typeof response.frontier !== 'object' || Array.isArray(response.frontier) ||
|
|
452
|
+
!Array.isArray(response.patches)) {
|
|
453
|
+
throw new SyncError('Invalid sync response', {
|
|
454
|
+
code: 'E_SYNC_PROTOCOL',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const result = this.applySyncResponse(response);
|
|
459
|
+
emit('applied', { applied: result.applied });
|
|
460
|
+
|
|
461
|
+
const durationMs = this._clock.now() - attemptStart;
|
|
462
|
+
emit('complete', { durationMs, applied: result.applied });
|
|
463
|
+
return { applied: result.applied, attempts: attempt };
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const syncResult = await retry(executeAttempt, {
|
|
468
|
+
retries,
|
|
469
|
+
delay: baseDelayMs,
|
|
470
|
+
maxDelay: maxDelayMs,
|
|
471
|
+
backoff: 'exponential',
|
|
472
|
+
jitter: 'decorrelated',
|
|
473
|
+
signal,
|
|
474
|
+
shouldRetry,
|
|
475
|
+
onRetry: (/** @type {Error} */ error, /** @type {number} */ attemptNumber, /** @type {number} */ delayMs) => {
|
|
476
|
+
if (typeof onStatus === 'function') {
|
|
477
|
+
onStatus(/** @type {any} */ ({ type: 'retrying', attempt: attemptNumber, delayMs, error })); // TODO(ts-cleanup): type sync protocol
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
this._logTiming('syncWith', t0, { metrics: `${syncResult.applied} patches applied` });
|
|
483
|
+
|
|
484
|
+
if (materializeAfterSync) {
|
|
485
|
+
if (!this._cachedState) { await this.materialize(); }
|
|
486
|
+
return { ...syncResult, state: /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (this._cachedState) };
|
|
487
|
+
}
|
|
488
|
+
return syncResult;
|
|
489
|
+
} catch (err) {
|
|
490
|
+
this._logTiming('syncWith', t0, { error: /** @type {Error} */ (err) });
|
|
491
|
+
if (/** @type {any} */ (err)?.name === 'AbortError') { // TODO(ts-cleanup): type error
|
|
492
|
+
const abortedError = new OperationAbortedError('syncWith', { reason: 'Signal received' });
|
|
493
|
+
if (typeof onStatus === 'function') {
|
|
494
|
+
onStatus({ type: 'failed', attempt, error: abortedError });
|
|
495
|
+
}
|
|
496
|
+
throw abortedError;
|
|
497
|
+
}
|
|
498
|
+
if (err instanceof RetryExhaustedError) {
|
|
499
|
+
const cause = /** @type {Error} */ (err.cause || err);
|
|
500
|
+
if (typeof onStatus === 'function') {
|
|
501
|
+
onStatus({ type: 'failed', attempt: err.attempts, error: cause });
|
|
502
|
+
}
|
|
503
|
+
throw cause;
|
|
504
|
+
}
|
|
505
|
+
if (typeof onStatus === 'function') {
|
|
506
|
+
onStatus({ type: 'failed', attempt, error: /** @type {Error} */ (err) });
|
|
507
|
+
}
|
|
508
|
+
throw err;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Starts a built-in sync server for this graph.
|
|
514
|
+
*
|
|
515
|
+
* @this {import('../WarpGraph.js').default}
|
|
516
|
+
* @param {Object} options
|
|
517
|
+
* @param {number} options.port - Port to listen on
|
|
518
|
+
* @param {string} [options.host='127.0.0.1'] - Host to bind
|
|
519
|
+
* @param {string} [options.path='/sync'] - Path to handle sync requests
|
|
520
|
+
* @param {number} [options.maxRequestBytes=4194304] - Max request size in bytes
|
|
521
|
+
* @param {import('../../ports/HttpServerPort.js').default} options.httpPort - HTTP server adapter (required)
|
|
522
|
+
* @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only' }} [options.auth] - Auth configuration
|
|
523
|
+
* @returns {Promise<{close: () => Promise<void>, url: string}>} Server handle
|
|
524
|
+
* @throws {Error} If port is not a number
|
|
525
|
+
* @throws {Error} If httpPort adapter is not provided
|
|
526
|
+
*/
|
|
527
|
+
export async function serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort, auth } = /** @type {any} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
528
|
+
if (typeof port !== 'number') {
|
|
529
|
+
throw new Error('serve() requires a numeric port');
|
|
530
|
+
}
|
|
531
|
+
if (!httpPort) {
|
|
532
|
+
throw new Error('serve() requires an httpPort adapter');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const authConfig = auth
|
|
536
|
+
? { ...auth, crypto: this._crypto, logger: this._logger || undefined }
|
|
537
|
+
: undefined;
|
|
538
|
+
|
|
539
|
+
const httpServer = new HttpSyncServer({
|
|
540
|
+
httpPort,
|
|
541
|
+
graph: this,
|
|
542
|
+
path,
|
|
543
|
+
host,
|
|
544
|
+
maxRequestBytes,
|
|
545
|
+
auth: authConfig,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return await httpServer.listen(port);
|
|
549
|
+
}
|
|
@@ -114,6 +114,26 @@ function getExitCode(err) {
|
|
|
114
114
|
return err?.details?.code ?? err?.exitCode ?? err?.code;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Checks if a Git error indicates a dangling or missing object.
|
|
119
|
+
* Exit code 128 with specific stderr patterns means the ref exists but
|
|
120
|
+
* points to a missing object. Other exit-128 failures (bad repo, corrupt
|
|
121
|
+
* index, permission errors) are NOT considered dangling and will re-throw.
|
|
122
|
+
* @param {GitError} err
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
function isDanglingObjectError(err) {
|
|
126
|
+
if (getExitCode(err) !== 128) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const stderr = (err.details?.stderr || '').toLowerCase();
|
|
130
|
+
return (
|
|
131
|
+
stderr.includes('bad object') ||
|
|
132
|
+
stderr.includes('not a valid object name') ||
|
|
133
|
+
stderr.includes('does not point to a valid object')
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
/**
|
|
118
138
|
* Checks whether a Git ref exists without resolving it.
|
|
119
139
|
* @param {function(Object): Promise<string>} execute - The git command executor function
|
|
@@ -129,6 +149,9 @@ async function refExists(execute, ref) {
|
|
|
129
149
|
if (getExitCode(err) === 1) {
|
|
130
150
|
return false;
|
|
131
151
|
}
|
|
152
|
+
if (isDanglingObjectError(err)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
132
155
|
throw err;
|
|
133
156
|
}
|
|
134
157
|
}
|
|
@@ -325,6 +348,20 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
325
348
|
};
|
|
326
349
|
}
|
|
327
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Retrieves the tree OID for a given commit SHA.
|
|
353
|
+
* @param {string} sha - The commit SHA to query
|
|
354
|
+
* @returns {Promise<string>} The tree OID pointed to by the commit
|
|
355
|
+
* @throws {Error} If the SHA is invalid
|
|
356
|
+
*/
|
|
357
|
+
async getCommitTree(sha) {
|
|
358
|
+
this._validateOid(sha);
|
|
359
|
+
const output = await this._executeWithRetry({
|
|
360
|
+
args: ['rev-parse', `${sha}^{tree}`]
|
|
361
|
+
});
|
|
362
|
+
return output.trim();
|
|
363
|
+
}
|
|
364
|
+
|
|
328
365
|
/**
|
|
329
366
|
* Returns raw git log output for a ref.
|
|
330
367
|
* @param {Object} options
|
|
@@ -493,7 +530,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
493
530
|
/**
|
|
494
531
|
* Reads the OID a ref points to.
|
|
495
532
|
* @param {string} ref - The ref name
|
|
496
|
-
* @returns {Promise<string|null>} The OID, or null if the ref does not exist
|
|
533
|
+
* @returns {Promise<string|null>} The OID, or null if the ref does not exist or points to a dangling/missing object
|
|
497
534
|
* @throws {Error} If the ref format is invalid
|
|
498
535
|
*/
|
|
499
536
|
async readRef(ref) {
|
|
@@ -511,10 +548,39 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
511
548
|
if (getExitCode(err) === 1) {
|
|
512
549
|
return null;
|
|
513
550
|
}
|
|
551
|
+
if (isDanglingObjectError(err)) {
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
514
554
|
throw err;
|
|
515
555
|
}
|
|
516
556
|
}
|
|
517
557
|
|
|
558
|
+
/**
|
|
559
|
+
* Atomically updates a ref using compare-and-swap semantics.
|
|
560
|
+
*
|
|
561
|
+
* Uses `git update-ref ref newOid expectedOid` which is atomic CAS.
|
|
562
|
+
* Fails if the ref does not currently point to expectedOid.
|
|
563
|
+
*
|
|
564
|
+
* @param {string} ref - The ref name
|
|
565
|
+
* @param {string} newOid - The new OID to set
|
|
566
|
+
* @param {string|null} expectedOid - The expected current OID, or null if the ref must not exist
|
|
567
|
+
* @returns {Promise<void>}
|
|
568
|
+
* @throws {Error} If the ref does not match the expected value (CAS mismatch)
|
|
569
|
+
*/
|
|
570
|
+
async compareAndSwapRef(ref, newOid, expectedOid) {
|
|
571
|
+
this._validateRef(ref);
|
|
572
|
+
this._validateOid(newOid);
|
|
573
|
+
// null means "ref must not exist" → use zero OID
|
|
574
|
+
const oldArg = expectedOid || '0'.repeat(newOid.length);
|
|
575
|
+
if (expectedOid) {
|
|
576
|
+
this._validateOid(expectedOid);
|
|
577
|
+
}
|
|
578
|
+
// Direct call — CAS failures are semantically expected and must NOT be retried.
|
|
579
|
+
await this.plumbing.execute({
|
|
580
|
+
args: ['update-ref', ref, newOid, oldArg],
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
518
584
|
/**
|
|
519
585
|
* Deletes a ref.
|
|
520
586
|
* @param {string} ref - The ref name to delete
|
|
@@ -253,6 +253,19 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort {
|
|
|
253
253
|
};
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* @param {string} sha
|
|
258
|
+
* @returns {Promise<string>}
|
|
259
|
+
*/
|
|
260
|
+
async getCommitTree(sha) {
|
|
261
|
+
validateOid(sha);
|
|
262
|
+
const commit = this._commits.get(sha);
|
|
263
|
+
if (!commit) {
|
|
264
|
+
throw new Error(`Commit not found: ${sha}`);
|
|
265
|
+
}
|
|
266
|
+
return commit.treeOid;
|
|
267
|
+
}
|
|
268
|
+
|
|
256
269
|
/**
|
|
257
270
|
* @param {string} sha
|
|
258
271
|
* @returns {Promise<boolean>}
|
|
@@ -356,6 +369,29 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort {
|
|
|
356
369
|
this._refs.delete(ref);
|
|
357
370
|
}
|
|
358
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Atomically updates a ref using compare-and-swap semantics.
|
|
374
|
+
* @param {string} ref - The ref name
|
|
375
|
+
* @param {string} newOid - The new OID to set
|
|
376
|
+
* @param {string|null} expectedOid - Expected current OID, or null if ref must not exist
|
|
377
|
+
* @returns {Promise<void>}
|
|
378
|
+
* @throws {Error} If the ref does not match the expected value (CAS mismatch)
|
|
379
|
+
*/
|
|
380
|
+
async compareAndSwapRef(ref, newOid, expectedOid) {
|
|
381
|
+
validateRef(ref);
|
|
382
|
+
validateOid(newOid);
|
|
383
|
+
if (expectedOid) {
|
|
384
|
+
validateOid(expectedOid);
|
|
385
|
+
}
|
|
386
|
+
const current = this._refs.get(ref) || null;
|
|
387
|
+
if (current !== expectedOid) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`CAS mismatch on ${ref}: expected ${expectedOid || '(none)'}, got ${current || '(none)'}`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
this._refs.set(ref, newOid);
|
|
393
|
+
}
|
|
394
|
+
|
|
359
395
|
/**
|
|
360
396
|
* @param {string} prefix
|
|
361
397
|
* @returns {Promise<string[]>}
|