@git-stunts/git-warp 10.3.2 → 10.7.0

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.
Files changed (108) hide show
  1. package/README.md +6 -3
  2. package/SECURITY.md +89 -1
  3. package/bin/warp-graph.js +574 -208
  4. package/index.d.ts +55 -0
  5. package/index.js +4 -0
  6. package/package.json +8 -4
  7. package/src/domain/WarpGraph.js +334 -161
  8. package/src/domain/crdt/LWW.js +1 -1
  9. package/src/domain/crdt/ORSet.js +10 -6
  10. package/src/domain/crdt/VersionVector.js +5 -1
  11. package/src/domain/errors/EmptyMessageError.js +2 -4
  12. package/src/domain/errors/ForkError.js +4 -0
  13. package/src/domain/errors/IndexError.js +4 -0
  14. package/src/domain/errors/OperationAbortedError.js +4 -0
  15. package/src/domain/errors/QueryError.js +4 -0
  16. package/src/domain/errors/SchemaUnsupportedError.js +4 -0
  17. package/src/domain/errors/ShardCorruptionError.js +2 -6
  18. package/src/domain/errors/ShardLoadError.js +2 -6
  19. package/src/domain/errors/ShardValidationError.js +2 -7
  20. package/src/domain/errors/StorageError.js +2 -6
  21. package/src/domain/errors/SyncError.js +4 -0
  22. package/src/domain/errors/TraversalError.js +4 -0
  23. package/src/domain/errors/WarpError.js +2 -4
  24. package/src/domain/errors/WormholeError.js +4 -0
  25. package/src/domain/services/AnchorMessageCodec.js +1 -4
  26. package/src/domain/services/BitmapIndexBuilder.js +10 -6
  27. package/src/domain/services/BitmapIndexReader.js +27 -21
  28. package/src/domain/services/BoundaryTransitionRecord.js +22 -15
  29. package/src/domain/services/CheckpointMessageCodec.js +1 -7
  30. package/src/domain/services/CheckpointSerializerV5.js +20 -19
  31. package/src/domain/services/CheckpointService.js +18 -18
  32. package/src/domain/services/CommitDagTraversalService.js +13 -1
  33. package/src/domain/services/DagPathFinding.js +40 -18
  34. package/src/domain/services/DagTopology.js +7 -6
  35. package/src/domain/services/DagTraversal.js +5 -3
  36. package/src/domain/services/Frontier.js +7 -6
  37. package/src/domain/services/HealthCheckService.js +15 -14
  38. package/src/domain/services/HookInstaller.js +64 -13
  39. package/src/domain/services/HttpSyncServer.js +88 -19
  40. package/src/domain/services/IndexRebuildService.js +12 -12
  41. package/src/domain/services/IndexStalenessChecker.js +13 -6
  42. package/src/domain/services/JoinReducer.js +28 -27
  43. package/src/domain/services/LogicalTraversal.js +7 -6
  44. package/src/domain/services/MessageCodecInternal.js +2 -0
  45. package/src/domain/services/ObserverView.js +6 -6
  46. package/src/domain/services/PatchBuilderV2.js +9 -9
  47. package/src/domain/services/PatchMessageCodec.js +1 -7
  48. package/src/domain/services/ProvenanceIndex.js +6 -8
  49. package/src/domain/services/ProvenancePayload.js +1 -2
  50. package/src/domain/services/QueryBuilder.js +29 -23
  51. package/src/domain/services/StateDiff.js +7 -7
  52. package/src/domain/services/StateSerializerV5.js +8 -6
  53. package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
  54. package/src/domain/services/SyncAuthService.js +396 -0
  55. package/src/domain/services/SyncProtocol.js +23 -26
  56. package/src/domain/services/TemporalQuery.js +4 -3
  57. package/src/domain/services/TranslationCost.js +4 -4
  58. package/src/domain/services/WormholeService.js +19 -15
  59. package/src/domain/types/TickReceipt.js +10 -6
  60. package/src/domain/types/WarpTypesV2.js +2 -3
  61. package/src/domain/utils/CachedValue.js +1 -1
  62. package/src/domain/utils/LRUCache.js +3 -3
  63. package/src/domain/utils/MinHeap.js +2 -2
  64. package/src/domain/utils/RefLayout.js +19 -0
  65. package/src/domain/utils/WriterId.js +2 -2
  66. package/src/domain/utils/defaultCodec.js +9 -2
  67. package/src/domain/utils/defaultCrypto.js +36 -0
  68. package/src/domain/utils/roaring.js +5 -5
  69. package/src/domain/utils/seekCacheKey.js +32 -0
  70. package/src/domain/warp/PatchSession.js +3 -3
  71. package/src/domain/warp/Writer.js +2 -2
  72. package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
  73. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
  74. package/src/infrastructure/adapters/ClockAdapter.js +2 -2
  75. package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
  76. package/src/infrastructure/adapters/GitGraphAdapter.js +25 -83
  77. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
  78. package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
  79. package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
  80. package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
  81. package/src/infrastructure/adapters/adapterValidation.js +90 -0
  82. package/src/infrastructure/codecs/CborCodec.js +16 -8
  83. package/src/ports/BlobPort.js +2 -2
  84. package/src/ports/CodecPort.js +2 -2
  85. package/src/ports/CommitPort.js +8 -21
  86. package/src/ports/ConfigPort.js +3 -3
  87. package/src/ports/CryptoPort.js +7 -7
  88. package/src/ports/GraphPersistencePort.js +12 -14
  89. package/src/ports/HttpServerPort.js +1 -5
  90. package/src/ports/IndexStoragePort.js +1 -0
  91. package/src/ports/LoggerPort.js +9 -9
  92. package/src/ports/RefPort.js +5 -5
  93. package/src/ports/SeekCachePort.js +73 -0
  94. package/src/ports/TreePort.js +3 -3
  95. package/src/visualization/layouts/converters.js +14 -7
  96. package/src/visualization/layouts/elkAdapter.js +17 -4
  97. package/src/visualization/layouts/elkLayout.js +23 -7
  98. package/src/visualization/layouts/index.js +3 -3
  99. package/src/visualization/renderers/ascii/check.js +30 -17
  100. package/src/visualization/renderers/ascii/graph.js +92 -1
  101. package/src/visualization/renderers/ascii/history.js +28 -26
  102. package/src/visualization/renderers/ascii/info.js +9 -7
  103. package/src/visualization/renderers/ascii/materialize.js +20 -16
  104. package/src/visualization/renderers/ascii/opSummary.js +15 -7
  105. package/src/visualization/renderers/ascii/path.js +1 -1
  106. package/src/visualization/renderers/ascii/seek.js +187 -23
  107. package/src/visualization/renderers/ascii/table.js +1 -1
  108. package/src/visualization/renderers/svg/index.js +5 -1
@@ -0,0 +1,396 @@
1
+ /**
2
+ * HMAC-SHA256 request signing and verification for the sync protocol.
3
+ *
4
+ * Provides:
5
+ * - Canonical payload construction
6
+ * - Request signing (client side)
7
+ * - Request verification with replay protection (server side)
8
+ *
9
+ * @module domain/services/SyncAuthService
10
+ */
11
+
12
+ import LRUCache from '../utils/LRUCache.js';
13
+ import defaultCrypto from '../utils/defaultCrypto.js';
14
+ import nullLogger from '../utils/nullLogger.js';
15
+
16
+ const SIG_VERSION = '1';
17
+ const SIG_PREFIX = 'warp-v1';
18
+ const HMAC_ALGO = 'sha256';
19
+ const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
20
+ const DEFAULT_NONCE_CAPACITY = 100_000;
21
+ const NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
22
+ const SIG_HEX_LENGTH = 64;
23
+ const HEX_PATTERN = /^[0-9a-f]+$/;
24
+ const MAX_TIMESTAMP_DIGITS = 16;
25
+
26
+ /**
27
+ * Canonicalizes a URL path for signature computation.
28
+ *
29
+ * @param {string} url - URL or path to canonicalize
30
+ * @returns {string} Canonical path (pathname + search, no fragment)
31
+ */
32
+ export function canonicalizePath(url) {
33
+ const parsed = new URL(url, 'http://localhost');
34
+ return parsed.pathname + (parsed.search || '');
35
+ }
36
+
37
+ /**
38
+ * Builds the canonical string that gets signed.
39
+ *
40
+ * @param {Object} params
41
+ * @param {string} params.keyId - Key identifier
42
+ * @param {string} params.method - HTTP method (uppercased by caller)
43
+ * @param {string} params.path - Canonical path
44
+ * @param {string} params.timestamp - Epoch milliseconds as string
45
+ * @param {string} params.nonce - UUIDv4 nonce
46
+ * @param {string} params.contentType - Content-Type header value
47
+ * @param {string} params.bodySha256 - Hex SHA-256 of request body
48
+ * @returns {string} Pipe-delimited canonical payload
49
+ */
50
+ export function buildCanonicalPayload({ keyId, method, path, timestamp, nonce, contentType, bodySha256 }) {
51
+ return `${SIG_PREFIX}|${keyId}|${method}|${path}|${timestamp}|${nonce}|${contentType}|${bodySha256}`;
52
+ }
53
+
54
+ /**
55
+ * Signs an outgoing sync request.
56
+ *
57
+ * @param {Object} params
58
+ * @param {string} params.method - HTTP method
59
+ * @param {string} params.path - Canonical path
60
+ * @param {string} params.contentType - Content-Type header value
61
+ * @param {Buffer|Uint8Array} params.body - Raw request body
62
+ * @param {string} params.secret - Shared secret
63
+ * @param {string} params.keyId - Key identifier
64
+ * @param {Object} deps
65
+ * @param {import('../../ports/CryptoPort.js').default} [deps.crypto] - Crypto port
66
+ * @returns {Promise<Record<string, string>>} Auth headers
67
+ */
68
+ export async function signSyncRequest({ method, path, contentType, body, secret, keyId }, { crypto } = {}) {
69
+ const c = crypto || defaultCrypto;
70
+ const timestamp = String(Date.now());
71
+ const nonce = globalThis.crypto.randomUUID();
72
+
73
+ const bodySha256 = await c.hash('sha256', body);
74
+ const canonical = buildCanonicalPayload({
75
+ keyId,
76
+ method: method.toUpperCase(),
77
+ path,
78
+ timestamp,
79
+ nonce,
80
+ contentType,
81
+ bodySha256,
82
+ });
83
+
84
+ const hmacBuf = await c.hmac(HMAC_ALGO, secret, canonical);
85
+ const signature = Buffer.from(hmacBuf).toString('hex');
86
+
87
+ return {
88
+ 'x-warp-sig-version': SIG_VERSION,
89
+ 'x-warp-key-id': keyId,
90
+ 'x-warp-timestamp': timestamp,
91
+ 'x-warp-nonce': nonce,
92
+ 'x-warp-signature': signature,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * @param {string} reason
98
+ * @param {number} status
99
+ * @returns {{ ok: false, reason: string, status: number }}
100
+ */
101
+ function fail(reason, status) {
102
+ return { ok: false, reason, status };
103
+ }
104
+
105
+ /**
106
+ * @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
107
+ */
108
+ function _freshMetrics() {
109
+ return {
110
+ authFailCount: 0,
111
+ replayRejectCount: 0,
112
+ nonceEvictions: 0,
113
+ clockSkewRejects: 0,
114
+ malformedRejects: 0,
115
+ logOnlyPassthroughs: 0,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Validates format of individual auth header values.
121
+ *
122
+ * @param {string} timestamp
123
+ * @param {string} nonce
124
+ * @param {string} signature
125
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true }}
126
+ */
127
+ function _checkHeaderFormats(timestamp, nonce, signature) {
128
+ if (!/^\d+$/.test(timestamp) || timestamp.length > MAX_TIMESTAMP_DIGITS) {
129
+ return fail('MALFORMED_TIMESTAMP', 400);
130
+ }
131
+
132
+ if (!NONCE_PATTERN.test(nonce)) {
133
+ return fail('MALFORMED_NONCE', 400);
134
+ }
135
+
136
+ if (signature.length !== SIG_HEX_LENGTH || !HEX_PATTERN.test(signature)) {
137
+ return fail('MALFORMED_SIGNATURE', 400);
138
+ }
139
+
140
+ return { ok: true };
141
+ }
142
+
143
+ /**
144
+ * @param {Record<string, string>|undefined} keys
145
+ */
146
+ function _validateKeys(keys) {
147
+ if (!keys || typeof keys !== 'object' || Object.keys(keys).length === 0) {
148
+ throw new Error('SyncAuthService requires a non-empty keys map');
149
+ }
150
+ }
151
+
152
+ export default class SyncAuthService {
153
+ /**
154
+ * @param {Object} options
155
+ * @param {Record<string, string>} options.keys - Key-id to secret mapping
156
+ * @param {'enforce'|'log-only'} [options.mode='enforce'] - Auth enforcement mode
157
+ * @param {number} [options.nonceCapacity] - Nonce LRU capacity
158
+ * @param {number} [options.maxClockSkewMs] - Max clock skew tolerance
159
+ * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - Crypto port
160
+ * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
161
+ * @param {() => number} [options.wallClockMs] - Wall clock function
162
+ */
163
+ constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
164
+ _validateKeys(keys);
165
+ this._keys = keys;
166
+ this._mode = mode;
167
+ this._crypto = crypto || defaultCrypto;
168
+ this._logger = logger || nullLogger;
169
+ this._wallClockMs = wallClockMs || (() => Date.now());
170
+ this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
171
+ this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
172
+ this._metrics = _freshMetrics();
173
+ }
174
+
175
+ /** @returns {'enforce'|'log-only'} */
176
+ get mode() {
177
+ return this._mode;
178
+ }
179
+
180
+ /**
181
+ * Validates auth header presence and format.
182
+ *
183
+ * @param {Record<string, string>} headers
184
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true, sigVersion: string, signature: string, timestamp: string, nonce: string, keyId: string }}
185
+ * @private
186
+ */
187
+ _validateHeaders(headers) {
188
+ const sigVersion = headers['x-warp-sig-version'];
189
+ if (sigVersion !== SIG_VERSION) {
190
+ return fail('INVALID_VERSION', 400);
191
+ }
192
+
193
+ const keyId = headers['x-warp-key-id'];
194
+ const signature = headers['x-warp-signature'];
195
+ const timestamp = headers['x-warp-timestamp'];
196
+ const nonce = headers['x-warp-nonce'];
197
+
198
+ if (!keyId || !signature || !timestamp || !nonce) {
199
+ return fail('MISSING_AUTH', 401);
200
+ }
201
+
202
+ const formatCheck = _checkHeaderFormats(timestamp, nonce, signature);
203
+ if (!formatCheck.ok) {
204
+ return formatCheck;
205
+ }
206
+
207
+ return { ok: true, sigVersion, signature, timestamp, nonce, keyId };
208
+ }
209
+
210
+ /**
211
+ * Checks that the timestamp is within the allowed clock skew.
212
+ *
213
+ * @param {string} timestamp - Epoch ms as string
214
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true }}
215
+ * @private
216
+ */
217
+ _validateFreshness(timestamp) {
218
+ const ts = Number(timestamp);
219
+ const now = this._wallClockMs();
220
+ if (Math.abs(now - ts) > this._maxClockSkewMs) {
221
+ this._metrics.clockSkewRejects += 1;
222
+ return fail('EXPIRED', 403);
223
+ }
224
+ return { ok: true };
225
+ }
226
+
227
+ /**
228
+ * Atomically reserves a nonce. Returns replay failure if already seen.
229
+ *
230
+ * @param {string} nonce
231
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true }}
232
+ * @private
233
+ */
234
+ _reserveNonce(nonce) {
235
+ if (this._nonceCache.has(nonce)) {
236
+ this._metrics.replayRejectCount += 1;
237
+ return fail('REPLAY', 403);
238
+ }
239
+
240
+ const sizeBefore = this._nonceCache.size;
241
+ this._nonceCache.set(nonce, true);
242
+ if (this._nonceCache.size <= sizeBefore && sizeBefore >= this._nonceCache.maxSize) {
243
+ this._metrics.nonceEvictions += 1;
244
+ }
245
+
246
+ return { ok: true };
247
+ }
248
+
249
+ /**
250
+ * Resolves the shared secret for a key-id.
251
+ *
252
+ * @param {string} keyId
253
+ * @returns {{ ok: false, reason: string, status: number } | { ok: true, secret: string }}
254
+ * @private
255
+ */
256
+ _resolveKey(keyId) {
257
+ const secret = this._keys[keyId];
258
+ if (!secret) {
259
+ return fail('UNKNOWN_KEY_ID', 401);
260
+ }
261
+ return { ok: true, secret };
262
+ }
263
+
264
+ /**
265
+ * Verifies the HMAC signature against the canonical payload.
266
+ *
267
+ * @param {Object} params
268
+ * @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} params.request
269
+ * @param {string} params.secret
270
+ * @param {string} params.keyId
271
+ * @param {string} params.timestamp
272
+ * @param {string} params.nonce
273
+ * @returns {Promise<{ ok: false, reason: string, status: number } | { ok: true }>}
274
+ * @private
275
+ */
276
+ async _verifySignature({ request, secret, keyId, timestamp, nonce }) {
277
+ const body = request.body || new Uint8Array(0);
278
+ const bodySha256 = await this._crypto.hash('sha256', body);
279
+ const contentType = request.headers['content-type'] || '';
280
+ const path = canonicalizePath(request.url || '/');
281
+
282
+ const canonical = buildCanonicalPayload({
283
+ keyId,
284
+ method: (request.method || 'POST').toUpperCase(),
285
+ path,
286
+ timestamp,
287
+ nonce,
288
+ contentType,
289
+ bodySha256,
290
+ });
291
+
292
+ const expectedBuf = await this._crypto.hmac(HMAC_ALGO, secret, canonical);
293
+ const receivedHex = request.headers['x-warp-signature'];
294
+
295
+ let receivedBuf;
296
+ try {
297
+ receivedBuf = Buffer.from(receivedHex, 'hex');
298
+ } catch {
299
+ return fail('INVALID_SIGNATURE', 401);
300
+ }
301
+
302
+ if (receivedBuf.length !== expectedBuf.length) {
303
+ return fail('INVALID_SIGNATURE', 401);
304
+ }
305
+
306
+ let equal;
307
+ try {
308
+ equal = this._crypto.timingSafeEqual(
309
+ Buffer.from(expectedBuf),
310
+ receivedBuf,
311
+ );
312
+ } catch {
313
+ return fail('INVALID_SIGNATURE', 401);
314
+ }
315
+
316
+ if (!equal) {
317
+ return fail('INVALID_SIGNATURE', 401);
318
+ }
319
+
320
+ return { ok: true };
321
+ }
322
+
323
+ /**
324
+ * Verifies an incoming sync request.
325
+ *
326
+ * @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} request
327
+ * @returns {Promise<{ ok: true } | { ok: false, reason: string, status: number }>}
328
+ */
329
+ async verify(request) {
330
+ const headers = request.headers || {};
331
+
332
+ const headerResult = this._validateHeaders(headers);
333
+ if (!headerResult.ok) {
334
+ this._metrics.malformedRejects += 1;
335
+ return this._fail('header validation failed', { reason: headerResult.reason }, headerResult);
336
+ }
337
+
338
+ const { timestamp, nonce, keyId } = headerResult;
339
+
340
+ const freshnessResult = this._validateFreshness(timestamp);
341
+ if (!freshnessResult.ok) {
342
+ return this._fail('clock skew rejected', { keyId, timestamp }, freshnessResult);
343
+ }
344
+
345
+ const keyResult = this._resolveKey(keyId);
346
+ if (!keyResult.ok) {
347
+ return this._fail('unknown key-id', { keyId }, keyResult);
348
+ }
349
+
350
+ const sigResult = await this._verifySignature({
351
+ request, secret: keyResult.secret, keyId, timestamp, nonce,
352
+ });
353
+ if (!sigResult.ok) {
354
+ return this._fail('signature mismatch', { keyId }, sigResult);
355
+ }
356
+
357
+ // Reserve nonce only after signature verification succeeds to avoid
358
+ // consuming nonces for requests with invalid signatures.
359
+ const nonceResult = this._reserveNonce(nonce);
360
+ if (!nonceResult.ok) {
361
+ return this._fail('replay detected', { keyId, nonce }, nonceResult);
362
+ }
363
+
364
+ return { ok: true };
365
+ }
366
+
367
+ /**
368
+ * Records an auth failure and returns the result.
369
+ * @param {string} message
370
+ * @param {Record<string, *>} context
371
+ * @param {{ ok: false, reason: string, status: number }} result
372
+ * @returns {{ ok: false, reason: string, status: number }}
373
+ * @private
374
+ */
375
+ _fail(message, context, result) {
376
+ this._metrics.authFailCount += 1;
377
+ this._logger.warn(`sync auth: ${message}`, context);
378
+ return result;
379
+ }
380
+
381
+ /**
382
+ * Increments the log-only passthrough counter.
383
+ */
384
+ recordLogOnlyPassthrough() {
385
+ this._metrics.logOnlyPassthroughs += 1;
386
+ }
387
+
388
+ /**
389
+ * Returns a snapshot of auth metrics.
390
+ *
391
+ * @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
392
+ */
393
+ getMetrics() {
394
+ return { ...this._metrics };
395
+ }
396
+ }
@@ -56,18 +56,16 @@ import { vvDeserialize } from '../crdt/VersionVector.js';
56
56
  * **Mutation**: This function mutates the input patch object for efficiency.
57
57
  * The original object reference is returned.
58
58
  *
59
- * @param {Object} patch - The raw decoded patch from CBOR
60
- * @param {Object|Map} [patch.context] - The causal context (version vector).
61
- * If present as a plain object, will be converted to a Map.
62
- * @param {Array} patch.ops - The patch operations (not modified)
63
- * @returns {Object} The same patch object with context converted to Map
59
+ * @param {{ context?: Object | Map<any, any>, ops: any[] }} patch - The raw decoded patch from CBOR.
60
+ * If context is present as a plain object, it will be converted to a Map.
61
+ * @returns {{ context?: Object | Map<any, any>, ops: any[] }} The same patch object with context converted to Map
64
62
  * @private
65
63
  */
66
64
  function normalizePatch(patch) {
67
65
  // Convert context from plain object to Map (VersionVector)
68
66
  // CBOR deserialization returns plain objects, but join() expects a Map
69
67
  if (patch.context && !(patch.context instanceof Map)) {
70
- patch.context = vvDeserialize(patch.context);
68
+ patch.context = vvDeserialize(/** @type {{ [x: string]: number }} */ (patch.context));
71
69
  }
72
70
  return patch;
73
71
  }
@@ -85,12 +83,12 @@ function normalizePatch(patch) {
85
83
  * **Commit message format**: The message is encoded using WarpMessageCodec
86
84
  * and contains metadata (schema version, writer info) plus the patch OID.
87
85
  *
88
- * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer
86
+ * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence layer
89
87
  * (uses CommitPort.showNode() + BlobPort.readBlob() methods)
90
88
  * @param {string} sha - The 40-character commit SHA to load the patch from
91
89
  * @param {Object} [options]
92
90
  * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization
93
- * @returns {Promise<Object>} The decoded and normalized patch object containing:
91
+ * @returns {Promise<{ context?: Object | Map<any, any>, ops: any[] }>} The decoded and normalized patch object containing:
94
92
  * - `ops`: Array of patch operations
95
93
  * - `context`: VersionVector (Map) of causal dependencies
96
94
  * - `writerId`: The writer who created this patch
@@ -101,7 +99,7 @@ function normalizePatch(patch) {
101
99
  * @throws {Error} If the patch blob cannot be CBOR-decoded (corrupted data)
102
100
  * @private
103
101
  */
104
- async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
102
+ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
105
103
  const codec = codecOpt || defaultCodec;
106
104
  // Read commit message to extract patch OID
107
105
  const message = await persistence.showNode(sha);
@@ -109,7 +107,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
109
107
 
110
108
  // Read and decode the patch blob
111
109
  const patchBuffer = await persistence.readBlob(decoded.patchOid);
112
- const patch = codec.decode(patchBuffer);
110
+ const patch = /** @type {{ context?: Object | Map<any, any>, ops: any[] }} */ (codec.decode(patchBuffer));
113
111
 
114
112
  // Normalize the patch (convert context from object to Map)
115
113
  return normalizePatch(patch);
@@ -129,7 +127,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
129
127
  * **Performance**: O(N) where N is the number of commits between fromSha and toSha.
130
128
  * Each commit requires two reads: commit info (for parent) and patch blob.
131
129
  *
132
- * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer
130
+ * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence layer
133
131
  * (uses CommitPort.getNodeInfo()/showNode() + BlobPort.readBlob() methods)
134
132
  * @param {string} graphName - Graph name (used in error messages, not for lookups)
135
133
  * @param {string} writerId - Writer ID (used in error messages, not for lookups)
@@ -154,7 +152,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) {
154
152
  * // Load ALL patches for a new writer
155
153
  * const patches = await loadPatchRange(persistence, 'events', 'new-writer', null, tipSha);
156
154
  */
157
- export async function loadPatchRange(persistence, graphName, writerId, fromSha, toSha, { codec } = {}) {
155
+ export async function loadPatchRange(persistence, graphName, writerId, fromSha, toSha, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
158
156
  const patches = [];
159
157
  let cur = toSha;
160
158
 
@@ -172,9 +170,9 @@ export async function loadPatchRange(persistence, graphName, writerId, fromSha,
172
170
 
173
171
  // If fromSha was specified but we didn't reach it, we have divergence
174
172
  if (fromSha && cur === null) {
175
- const err = new Error(
173
+ const err = /** @type {Error & { code: string }} */ (new Error(
176
174
  `Divergence detected: ${toSha} does not descend from ${fromSha} for writer ${writerId}`
177
- );
175
+ ));
178
176
  err.code = 'E_SYNC_DIVERGENCE';
179
177
  throw err;
180
178
  }
@@ -214,11 +212,7 @@ export async function loadPatchRange(persistence, graphName, writerId, fromSha,
214
212
  * Maps writerId to the SHA of their latest patch commit.
215
213
  * @param {Map<string, string>} remoteFrontier - Remote writer heads.
216
214
  * Maps writerId to the SHA of their latest patch commit.
217
- * @returns {Object} Sync delta containing:
218
- * - `needFromRemote`: Map<writerId, {from: string|null, to: string}> - Patches local needs
219
- * - `needFromLocal`: Map<writerId, {from: string|null, to: string}> - Patches remote needs
220
- * - `newWritersForLocal`: string[] - Writers that local has never seen
221
- * - `newWritersForRemote`: string[] - Writers that remote has never seen
215
+ * @returns {{ needFromRemote: Map<string, {from: string|null, to: string}>, needFromLocal: Map<string, {from: string|null, to: string}>, newWritersForLocal: string[], newWritersForRemote: string[] }} Sync delta
222
216
  *
223
217
  * @example
224
218
  * const local = new Map([['w1', 'sha-a'], ['w2', 'sha-b']]);
@@ -333,13 +327,14 @@ export function computeSyncDelta(localFrontier, remoteFrontier) {
333
327
  */
334
328
  export function createSyncRequest(frontier) {
335
329
  // Convert Map to plain object for serialization
330
+ /** @type {{ [x: string]: string }} */
336
331
  const frontierObj = {};
337
332
  for (const [writerId, sha] of frontier) {
338
333
  frontierObj[writerId] = sha;
339
334
  }
340
335
 
341
336
  return {
342
- type: 'sync-request',
337
+ type: /** @type {'sync-request'} */ ('sync-request'),
343
338
  frontier: frontierObj,
344
339
  };
345
340
  }
@@ -363,7 +358,7 @@ export function createSyncRequest(frontier) {
363
358
  *
364
359
  * @param {SyncRequest} request - Incoming sync request containing the requester's frontier
365
360
  * @param {Map<string, string>} localFrontier - Local frontier (what this node has)
366
- * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence
361
+ * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence
367
362
  * layer for loading patches (uses CommitPort + BlobPort methods)
368
363
  * @param {string} graphName - Graph name for error messages and logging
369
364
  * @returns {Promise<SyncResponse>} Response containing local frontier and patches.
@@ -379,7 +374,7 @@ export function createSyncRequest(frontier) {
379
374
  * res.json(response);
380
375
  * });
381
376
  */
382
- export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = {}) {
377
+ export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
383
378
  // Convert incoming frontier from object to Map
384
379
  const remoteFrontier = new Map(Object.entries(request.frontier));
385
380
 
@@ -406,7 +401,7 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
406
401
  } catch (err) {
407
402
  // If we detect divergence, skip this writer
408
403
  // The requester may need to handle this separately
409
- if (err.code === 'E_SYNC_DIVERGENCE' || err.message.includes('Divergence detected')) {
404
+ if (/** @type {any} */ (err).code === 'E_SYNC_DIVERGENCE' || /** @type {any} */ (err).message?.includes('Divergence detected')) { // TODO(ts-cleanup): type error
410
405
  continue;
411
406
  }
412
407
  throw err;
@@ -414,13 +409,14 @@ export async function processSyncRequest(request, localFrontier, persistence, gr
414
409
  }
415
410
 
416
411
  // Convert local frontier to plain object
412
+ /** @type {{ [x: string]: string }} */
417
413
  const frontierObj = {};
418
414
  for (const [writerId, sha] of localFrontier) {
419
415
  frontierObj[writerId] = sha;
420
416
  }
421
417
 
422
418
  return {
423
- type: 'sync-response',
419
+ type: /** @type {'sync-response'} */ ('sync-response'),
424
420
  frontier: frontierObj,
425
421
  patches,
426
422
  };
@@ -495,7 +491,7 @@ export function applySyncResponse(response, state, frontier) {
495
491
  // will prevent silent data loss until the reader is upgraded.
496
492
  assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3);
497
493
  // Apply patch to state
498
- join(newState, normalizedPatch, sha);
494
+ join(newState, /** @type {*} */ (normalizedPatch), sha); // TODO(ts-cleanup): type patch array
499
495
  applied++;
500
496
  }
501
497
 
@@ -580,13 +576,14 @@ export function syncNeeded(localFrontier, remoteFrontier) {
580
576
  * }
581
577
  */
582
578
  export function createEmptySyncResponse(frontier) {
579
+ /** @type {{ [x: string]: string }} */
583
580
  const frontierObj = {};
584
581
  for (const [writerId, sha] of frontier) {
585
582
  frontierObj[writerId] = sha;
586
583
  }
587
584
 
588
585
  return {
589
- type: 'sync-response',
586
+ type: /** @type {'sync-response'} */ ('sync-response'),
590
587
  frontier: frontierObj,
591
588
  patches: [],
592
589
  };
@@ -60,10 +60,11 @@ function unwrapValue(value) {
60
60
  *
61
61
  * @param {import('./JoinReducer.js').WarpStateV5} state - Current state
62
62
  * @param {string} nodeId - Node ID to extract
63
- * @returns {{ id: string, exists: boolean, props: Object<string, *> }}
63
+ * @returns {{ id: string, exists: boolean, props: Record<string, *> }}
64
64
  */
65
65
  function extractNodeSnapshot(state, nodeId) {
66
66
  const exists = orsetContains(state.nodeAlive, nodeId);
67
+ /** @type {Record<string, *>} */
67
68
  const props = {};
68
69
 
69
70
  if (exists) {
@@ -108,7 +109,7 @@ export class TemporalQuery {
108
109
  * @param {string} nodeId - The node ID to evaluate
109
110
  * @param {Function} predicate - Predicate receiving node snapshot
110
111
  * `{ id, exists, props }`. Should return boolean.
111
- * @param {{ since?: number }} [options={}] - Options
112
+ * @param {Object} [options={}] - Options
112
113
  * @param {number} [options.since=0] - Minimum Lamport tick (inclusive).
113
114
  * Only patches with lamport >= since are considered.
114
115
  * @returns {Promise<boolean>} True if predicate held at every tick
@@ -161,7 +162,7 @@ export class TemporalQuery {
161
162
  * @param {string} nodeId - The node ID to evaluate
162
163
  * @param {Function} predicate - Predicate receiving node snapshot
163
164
  * `{ id, exists, props }`. Should return boolean.
164
- * @param {{ since?: number }} [options={}] - Options
165
+ * @param {Object} [options={}] - Options
165
166
  * @param {number} [options.since=0] - Minimum Lamport tick (inclusive).
166
167
  * Only patches with lamport >= since are considered.
167
168
  * @returns {Promise<boolean>} True if predicate held at any tick
@@ -94,8 +94,8 @@ function zeroCost() {
94
94
  /**
95
95
  * Counts how many items in `source` are absent from `targetSet`.
96
96
  *
97
- * @param {Array|Set} source - Source collection
98
- * @param {Set} targetSet - Target set to test against
97
+ * @param {Array<string>|Set<string>} source - Source collection
98
+ * @param {Set<string>} targetSet - Target set to test against
99
99
  * @returns {number}
100
100
  */
101
101
  function countMissing(source, targetSet) {
@@ -141,7 +141,7 @@ function computeEdgeLoss(state, nodesASet, nodesBSet) {
141
141
  * Counts lost properties for a single node between two observer configs.
142
142
  *
143
143
  * @param {Map<string, boolean>} nodeProps - Property keys for the node
144
- * @param {{ configA: Object, configB: Object, nodeInB: boolean }} opts
144
+ * @param {{ configA: {expose?: string[], redact?: string[]}, configB: {expose?: string[], redact?: string[]}, nodeInB: boolean }} opts
145
145
  * @returns {{ propsInA: number, lostProps: number }}
146
146
  */
147
147
  function countNodePropLoss(nodeProps, { configA, configB, nodeInB }) {
@@ -157,7 +157,7 @@ function countNodePropLoss(nodeProps, { configA, configB, nodeInB }) {
157
157
  * Computes property loss across all A-visible nodes.
158
158
  *
159
159
  * @param {*} state - WarpStateV5
160
- * @param {{ nodesA: string[], nodesBSet: Set<string>, configA: Object, configB: Object }} opts
160
+ * @param {{ nodesA: string[], nodesBSet: Set<string>, configA: {expose?: string[], redact?: string[]}, configB: {expose?: string[], redact?: string[]} }} opts
161
161
  * @returns {number} propLoss fraction
162
162
  */
163
163
  function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {