@git-stunts/git-warp 10.8.0 → 11.3.3
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 +80 -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/patch.js +142 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +235 -0
- package/bin/cli/commands/registry.js +32 -0
- package/bin/cli/commands/seek.js +598 -0
- package/bin/cli/commands/tree.js +230 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +114 -0
- package/bin/cli/commands/view.js +46 -0
- package/bin/cli/infrastructure.js +350 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +96 -0
- package/bin/presenters/index.js +41 -9
- package/bin/presenters/json.js +14 -12
- package/bin/presenters/text.js +286 -28
- package/bin/warp-graph.js +5 -2346
- package/index.d.ts +111 -21
- package/index.js +2 -0
- package/package.json +10 -8
- package/src/domain/WarpGraph.js +109 -3252
- package/src/domain/crdt/ORSet.js +8 -8
- package/src/domain/errors/EmptyMessageError.js +2 -2
- package/src/domain/errors/ForkError.js +1 -1
- package/src/domain/errors/IndexError.js +1 -1
- package/src/domain/errors/OperationAbortedError.js +1 -1
- package/src/domain/errors/QueryError.js +3 -3
- package/src/domain/errors/SchemaUnsupportedError.js +1 -1
- package/src/domain/errors/ShardCorruptionError.js +2 -2
- package/src/domain/errors/ShardLoadError.js +2 -2
- package/src/domain/errors/ShardValidationError.js +4 -4
- package/src/domain/errors/StorageError.js +2 -2
- package/src/domain/errors/SyncError.js +1 -1
- package/src/domain/errors/TraversalError.js +1 -1
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/WarpError.js +2 -2
- package/src/domain/errors/WormholeError.js +1 -1
- 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 +707 -0
- package/src/domain/services/BitmapIndexBuilder.js +3 -3
- package/src/domain/services/BitmapIndexReader.js +28 -19
- package/src/domain/services/BoundaryTransitionRecord.js +18 -17
- package/src/domain/services/CheckpointSerializerV5.js +17 -16
- package/src/domain/services/CheckpointService.js +2 -2
- package/src/domain/services/CommitDagTraversalService.js +13 -13
- package/src/domain/services/DagPathFinding.js +7 -7
- package/src/domain/services/DagTopology.js +1 -1
- package/src/domain/services/DagTraversal.js +1 -1
- package/src/domain/services/HealthCheckService.js +1 -1
- package/src/domain/services/HookInstaller.js +1 -1
- package/src/domain/services/HttpSyncServer.js +120 -55
- package/src/domain/services/IndexRebuildService.js +7 -7
- package/src/domain/services/IndexStalenessChecker.js +4 -3
- package/src/domain/services/JoinReducer.js +11 -11
- package/src/domain/services/LogicalTraversal.js +1 -1
- package/src/domain/services/MessageCodecInternal.js +4 -1
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/MigrationService.js +1 -1
- package/src/domain/services/ObserverView.js +8 -8
- package/src/domain/services/PatchBuilderV2.js +42 -26
- package/src/domain/services/ProvenanceIndex.js +1 -1
- package/src/domain/services/ProvenancePayload.js +1 -1
- package/src/domain/services/QueryBuilder.js +3 -3
- package/src/domain/services/StateDiff.js +14 -11
- package/src/domain/services/StateSerializerV5.js +2 -2
- package/src/domain/services/StreamingBitmapIndexBuilder.js +26 -24
- package/src/domain/services/SyncAuthService.js +71 -4
- package/src/domain/services/SyncProtocol.js +25 -11
- package/src/domain/services/TemporalQuery.js +9 -6
- package/src/domain/services/TranslationCost.js +7 -5
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/services/WormholeService.js +16 -7
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +195 -0
- package/src/domain/trust/TrustRecordService.js +281 -0
- package/src/domain/trust/TrustStateBuilder.js +222 -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/TickReceipt.js +1 -1
- package/src/domain/types/WarpErrors.js +45 -0
- package/src/domain/types/WarpOptions.js +29 -0
- package/src/domain/types/WarpPersistence.js +41 -0
- package/src/domain/types/WarpTypes.js +2 -2
- package/src/domain/types/WarpTypesV2.js +2 -2
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/MinHeap.js +6 -5
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/utils/canonicalStringify.js +5 -4
- package/src/domain/utils/roaring.js +31 -5
- package/src/domain/warp/PatchSession.js +26 -17
- 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 +254 -0
- package/src/domain/warp/checkpoint.methods.js +401 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +238 -0
- package/src/domain/warp/materializeAdvanced.methods.js +350 -0
- package/src/domain/warp/patch.methods.js +554 -0
- package/src/domain/warp/provenance.methods.js +286 -0
- package/src/domain/warp/query.methods.js +280 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +554 -0
- package/src/globals.d.ts +64 -0
- package/src/infrastructure/adapters/BunHttpAdapter.js +14 -9
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +9 -4
- package/src/infrastructure/adapters/DenoHttpAdapter.js +5 -6
- package/src/infrastructure/adapters/GitGraphAdapter.js +79 -11
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/infrastructure/adapters/NodeHttpAdapter.js +2 -2
- package/src/infrastructure/adapters/WebCryptoAdapter.js +2 -2
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/visualization/layouts/converters.js +2 -2
- package/src/visualization/layouts/elkAdapter.js +1 -1
- package/src/visualization/layouts/elkLayout.js +10 -7
- package/src/visualization/layouts/index.js +1 -1
- package/src/visualization/renderers/ascii/seek.js +16 -6
- package/src/visualization/renderers/svg/index.js +1 -1
- package/src/hooks/post-merge.sh +0 -60
|
@@ -0,0 +1,554 @@
|
|
|
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
|
+
}
|
package/src/globals.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ambient declarations for Deno and Bun runtime globals.
|
|
3
|
+
*
|
|
4
|
+
* These cover ONLY the APIs actually used in this codebase:
|
|
5
|
+
* - Deno.serve() (DenoHttpAdapter.js)
|
|
6
|
+
* - Deno.env.get() (bin/cli/infrastructure.js)
|
|
7
|
+
* - Bun.serve() (BunHttpAdapter.js)
|
|
8
|
+
*
|
|
9
|
+
* Do NOT install @types/deno or @types/bun — this file is intentionally
|
|
10
|
+
* narrow to avoid pulling in thousands of unrelated declarations.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/* ------------------------------------------------------------------ */
|
|
14
|
+
/* Deno */
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
|
|
17
|
+
interface DenoAddr {
|
|
18
|
+
transport: string;
|
|
19
|
+
hostname: string;
|
|
20
|
+
port: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface DenoServer {
|
|
24
|
+
shutdown(): Promise<void>;
|
|
25
|
+
addr: DenoAddr;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DenoServeOptions {
|
|
29
|
+
port?: number;
|
|
30
|
+
hostname?: string;
|
|
31
|
+
onListen?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface DenoEnv {
|
|
35
|
+
get(name: string): string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
declare namespace Deno {
|
|
39
|
+
const env: DenoEnv;
|
|
40
|
+
function serve(
|
|
41
|
+
options: DenoServeOptions,
|
|
42
|
+
handler: (request: Request) => Promise<Response> | Response,
|
|
43
|
+
): DenoServer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ */
|
|
47
|
+
/* Bun */
|
|
48
|
+
/* ------------------------------------------------------------------ */
|
|
49
|
+
|
|
50
|
+
interface BunServer {
|
|
51
|
+
stop(closeActiveConnections?: boolean): Promise<void>;
|
|
52
|
+
hostname: string;
|
|
53
|
+
port: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface BunServeOptions {
|
|
57
|
+
port?: number;
|
|
58
|
+
hostname?: string;
|
|
59
|
+
fetch: (request: Request) => Promise<Response> | Response;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
declare namespace Bun {
|
|
63
|
+
function serve(options: BunServeOptions): BunServer;
|
|
64
|
+
}
|
|
@@ -106,8 +106,8 @@ function createFetchHandler(requestHandler, logger) {
|
|
|
106
106
|
const portReq = await toPortRequest(request);
|
|
107
107
|
const portRes = await requestHandler(portReq);
|
|
108
108
|
return toResponse(portRes);
|
|
109
|
-
} catch (
|
|
110
|
-
if (err.status === 413) {
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (typeof err === 'object' && err !== null && /** @type {{status?: number}} */ (err).status === 413) {
|
|
111
111
|
return new Response(PAYLOAD_TOO_LARGE, {
|
|
112
112
|
status: 413,
|
|
113
113
|
headers: { 'Content-Type': 'text/plain', 'Content-Length': PAYLOAD_TOO_LARGE_LENGTH },
|
|
@@ -125,6 +125,10 @@ function createFetchHandler(requestHandler, logger) {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* @typedef {{ hostname: string, port: number, stop: (closeActiveConnections?: boolean) => Promise<void> }} BunServer
|
|
130
|
+
*/
|
|
131
|
+
|
|
128
132
|
/**
|
|
129
133
|
* Starts a Bun server and invokes the callback with (null) on success
|
|
130
134
|
* or (err) on failure.
|
|
@@ -132,12 +136,11 @@ function createFetchHandler(requestHandler, logger) {
|
|
|
132
136
|
* Note: Bun.serve() is synchronous, so cb fires on the same tick
|
|
133
137
|
* (unlike Node's server.listen which defers via the event loop).
|
|
134
138
|
*
|
|
135
|
-
* @param {
|
|
139
|
+
* @param {BunServeOptions} serveOptions
|
|
136
140
|
* @param {Function|undefined} cb - Node-style callback
|
|
137
|
-
* @returns {
|
|
141
|
+
* @returns {BunServer} The Bun server instance
|
|
138
142
|
*/
|
|
139
143
|
function startServer(serveOptions, cb) {
|
|
140
|
-
// @ts-expect-error — Bun global is only available in Bun runtime
|
|
141
144
|
const server = globalThis.Bun.serve(serveOptions);
|
|
142
145
|
if (cb) {
|
|
143
146
|
cb(null);
|
|
@@ -148,13 +151,15 @@ function startServer(serveOptions, cb) {
|
|
|
148
151
|
/**
|
|
149
152
|
* Safely stops a Bun server, forwarding errors to the callback.
|
|
150
153
|
*
|
|
151
|
-
* @param {{ server:
|
|
154
|
+
* @param {{ server: BunServer | null }} state - Shared mutable state
|
|
152
155
|
* @param {Function} [callback]
|
|
153
156
|
*/
|
|
154
157
|
function stopServer(state, callback) {
|
|
155
158
|
try {
|
|
156
159
|
if (state.server) {
|
|
157
|
-
|
|
160
|
+
// stop() synchronously halts the listener; the returned Promise
|
|
161
|
+
// represents draining of active connections — safe to ignore here.
|
|
162
|
+
void state.server.stop();
|
|
158
163
|
state.server = null;
|
|
159
164
|
}
|
|
160
165
|
if (callback) {
|
|
@@ -192,7 +197,7 @@ export default class BunHttpAdapter extends HttpServerPort {
|
|
|
192
197
|
*/
|
|
193
198
|
createServer(requestHandler) {
|
|
194
199
|
const fetchHandler = createFetchHandler(requestHandler, this._logger);
|
|
195
|
-
/** @type {{ server:
|
|
200
|
+
/** @type {{ server: BunServer | null }} */
|
|
196
201
|
const state = { server: null };
|
|
197
202
|
|
|
198
203
|
return {
|
|
@@ -204,7 +209,7 @@ export default class BunHttpAdapter extends HttpServerPort {
|
|
|
204
209
|
listen(port, host, callback) {
|
|
205
210
|
const cb = typeof host === 'function' ? host : callback;
|
|
206
211
|
const bindHost = typeof host === 'string' ? host : undefined;
|
|
207
|
-
/** @type {
|
|
212
|
+
/** @type {BunServeOptions} */
|
|
208
213
|
const serveOptions = { port, fetch: fetchHandler };
|
|
209
214
|
|
|
210
215
|
if (bindHost !== undefined) {
|