@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,311 @@
1
+ /**
2
+ * CAS-backed seek materialization cache adapter.
3
+ *
4
+ * Implements SeekCachePort using @git-stunts/git-cas for persistent storage
5
+ * of serialized WarpStateV5 snapshots. Each cached state is stored as a CAS
6
+ * asset (chunked blobs + manifest tree), and an index ref tracks the mapping
7
+ * from cache keys to tree OIDs.
8
+ *
9
+ * Index ref: `refs/warp/<graphName>/seek-cache` → blob containing JSON index.
10
+ *
11
+ * Blobs are loose Git objects — `git gc` prunes them using the configured
12
+ * prune expiry (default ~2 weeks). Use vault pinning for GC-safe persistence.
13
+ *
14
+ * **Requires Node >= 22.0.0** (inherited from `@git-stunts/git-cas`).
15
+ *
16
+ * @module infrastructure/adapters/CasSeekCacheAdapter
17
+ */
18
+
19
+ import SeekCachePort from '../../ports/SeekCachePort.js';
20
+ import { buildSeekCacheRef } from '../../domain/utils/RefLayout.js';
21
+ import { Readable } from 'node:stream';
22
+
23
+ const DEFAULT_MAX_ENTRIES = 200;
24
+ const INDEX_SCHEMA_VERSION = 1;
25
+ const MAX_CAS_RETRIES = 3;
26
+
27
+ /**
28
+ * @typedef {Object} IndexEntry
29
+ * @property {string} treeOid - Git tree OID of the CAS asset
30
+ * @property {string} createdAt - ISO 8601 timestamp
31
+ * @property {number} ceiling - Lamport ceiling tick
32
+ * @property {string} frontierHash - Hex hash portion of the cache key
33
+ * @property {number} sizeBytes - Serialized state size in bytes
34
+ * @property {string} codec - Codec identifier (e.g. 'cbor-v1')
35
+ * @property {number} schemaVersion - Index entry schema version
36
+ * @property {string} [lastAccessedAt] - ISO 8601 timestamp of last read (for LRU eviction)
37
+ */
38
+
39
+ /**
40
+ * @typedef {Object} CacheIndex
41
+ * @property {number} schemaVersion - Index-level schema version
42
+ * @property {Record<string, IndexEntry>} entries - Map of cacheKey → entry
43
+ */
44
+
45
+ export default class CasSeekCacheAdapter extends SeekCachePort {
46
+ /**
47
+ * @param {{ persistence: *, plumbing: *, graphName: string, maxEntries?: number }} options
48
+ */
49
+ constructor({ persistence, plumbing, graphName, maxEntries }) {
50
+ super();
51
+ this._persistence = persistence;
52
+ this._plumbing = plumbing;
53
+ this._graphName = graphName;
54
+ this._maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
55
+ this._ref = buildSeekCacheRef(graphName);
56
+ this._casPromise = null;
57
+ }
58
+
59
+ /**
60
+ * Lazily initializes the ContentAddressableStore.
61
+ * @private
62
+ * @returns {Promise<*>}
63
+ */
64
+ async _getCas() {
65
+ if (!this._casPromise) {
66
+ this._casPromise = this._initCas().catch((err) => {
67
+ this._casPromise = null;
68
+ throw err;
69
+ });
70
+ }
71
+ return await this._casPromise;
72
+ }
73
+
74
+ /**
75
+ * @private
76
+ * @returns {Promise<*>}
77
+ */
78
+ async _initCas() {
79
+ const { default: ContentAddressableStore } = await import(
80
+ /* webpackIgnore: true */ '@git-stunts/git-cas'
81
+ );
82
+ return ContentAddressableStore.createCbor({ plumbing: this._plumbing });
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Index management
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Reads the current cache index from the ref.
91
+ * @private
92
+ * @returns {Promise<CacheIndex>}
93
+ */
94
+ async _readIndex() {
95
+ const oid = await this._persistence.readRef(this._ref);
96
+ if (!oid) {
97
+ return { schemaVersion: INDEX_SCHEMA_VERSION, entries: {} };
98
+ }
99
+ try {
100
+ const buf = await this._persistence.readBlob(oid);
101
+ const parsed = JSON.parse(buf.toString('utf8'));
102
+ if (parsed.schemaVersion !== INDEX_SCHEMA_VERSION) {
103
+ return { schemaVersion: INDEX_SCHEMA_VERSION, entries: {} };
104
+ }
105
+ return parsed;
106
+ } catch {
107
+ return { schemaVersion: INDEX_SCHEMA_VERSION, entries: {} };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Writes the cache index blob and updates the ref.
113
+ * @private
114
+ * @param {CacheIndex} index - The index to write
115
+ * @returns {Promise<void>}
116
+ */
117
+ async _writeIndex(index) {
118
+ const json = JSON.stringify(index);
119
+ const oid = await this._persistence.writeBlob(Buffer.from(json, 'utf8'));
120
+ await this._persistence.updateRef(this._ref, oid);
121
+ }
122
+
123
+ /**
124
+ * Mutates the index with retry on write failure.
125
+ *
126
+ * Note: this adapter is single-writer — concurrent index mutations from
127
+ * separate processes may lose updates. The retry loop handles transient
128
+ * I/O errors (e.g. temporary lock contention), not true CAS conflicts.
129
+ *
130
+ * @private
131
+ * @param {function(CacheIndex): CacheIndex} mutate - Mutation function applied to current index
132
+ * @returns {Promise<CacheIndex>} The mutated index
133
+ */
134
+ async _mutateIndex(mutate) {
135
+ /** @type {*} */ // TODO(ts-cleanup): type CAS retry error
136
+ let lastErr;
137
+ for (let attempt = 0; attempt < MAX_CAS_RETRIES; attempt++) {
138
+ const index = await this._readIndex();
139
+ const mutated = mutate(index);
140
+ try {
141
+ await this._writeIndex(mutated);
142
+ return mutated;
143
+ } catch (err) {
144
+ lastErr = err;
145
+ // Transient write failure — retry with fresh read
146
+ if (attempt === MAX_CAS_RETRIES - 1) {
147
+ throw new Error(`CasSeekCacheAdapter: index update failed after retries: ${lastErr.message}`);
148
+ }
149
+ }
150
+ }
151
+ /* c8 ignore next - unreachable */
152
+ throw new Error('CasSeekCacheAdapter: index update failed');
153
+ }
154
+
155
+ /**
156
+ * Evicts oldest entries when index exceeds maxEntries.
157
+ * @private
158
+ * @param {CacheIndex} index
159
+ * @returns {CacheIndex}
160
+ */
161
+ _enforceMaxEntries(index) {
162
+ const keys = Object.keys(index.entries);
163
+ if (keys.length <= this._maxEntries) {
164
+ return index;
165
+ }
166
+ // Sort by last access (or creation) ascending — evict least recently used
167
+ const sorted = keys.sort((a, b) => {
168
+ const ea = index.entries[a];
169
+ const eb = index.entries[b];
170
+ const ta = ea.lastAccessedAt || ea.createdAt || '';
171
+ const tb = eb.lastAccessedAt || eb.createdAt || '';
172
+ return ta < tb ? -1 : ta > tb ? 1 : 0;
173
+ });
174
+ const toEvict = sorted.slice(0, keys.length - this._maxEntries);
175
+ for (const k of toEvict) {
176
+ delete index.entries[k];
177
+ }
178
+ return index;
179
+ }
180
+
181
+ /**
182
+ * Parses ceiling and frontierHash from a versioned cache key.
183
+ * @private
184
+ * @param {string} key - e.g. 'v1:t42-abcdef...'
185
+ * @returns {{ ceiling: number, frontierHash: string }}
186
+ */
187
+ _parseKey(key) {
188
+ const colonIdx = key.indexOf(':');
189
+ const rest = colonIdx >= 0 ? key.slice(colonIdx + 1) : key;
190
+ const dashIdx = rest.indexOf('-');
191
+ const ceiling = parseInt(rest.slice(1, dashIdx), 10);
192
+ const frontierHash = rest.slice(dashIdx + 1);
193
+ return { ceiling, frontierHash };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // SeekCachePort implementation
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * @override
202
+ * @param {string} key
203
+ * @returns {Promise<Buffer|null>}
204
+ */
205
+ async get(key) {
206
+ const cas = await this._getCas();
207
+ const index = await this._readIndex();
208
+ const entry = index.entries[key];
209
+ if (!entry) {
210
+ return null;
211
+ }
212
+
213
+ try {
214
+ const manifest = await cas.readManifest({ treeOid: entry.treeOid });
215
+ const { buffer } = await cas.restore({ manifest });
216
+ // Update lastAccessedAt for LRU eviction ordering
217
+ await this._mutateIndex((idx) => {
218
+ if (idx.entries[key]) {
219
+ idx.entries[key].lastAccessedAt = new Date().toISOString();
220
+ }
221
+ return idx;
222
+ });
223
+ return buffer;
224
+ } catch {
225
+ // Blob GC'd or corrupted — self-heal by removing dead entry
226
+ await this._mutateIndex((idx) => {
227
+ delete idx.entries[key];
228
+ return idx;
229
+ });
230
+ return null;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @override
236
+ * @param {string} key
237
+ * @param {Buffer} buffer
238
+ * @returns {Promise<void>}
239
+ */
240
+ async set(key, buffer) {
241
+ const cas = await this._getCas();
242
+ const { ceiling, frontierHash } = this._parseKey(key);
243
+
244
+ // Store buffer as CAS asset
245
+ const source = Readable.from([buffer]);
246
+ const manifest = await cas.store({
247
+ source,
248
+ slug: key,
249
+ filename: 'state.cbor',
250
+ });
251
+ const treeOid = await cas.createTree({ manifest });
252
+
253
+ // Update index with rich metadata
254
+ await this._mutateIndex((index) => {
255
+ index.entries[key] = {
256
+ treeOid,
257
+ createdAt: new Date().toISOString(),
258
+ ceiling,
259
+ frontierHash,
260
+ sizeBytes: buffer.length,
261
+ codec: 'cbor-v1',
262
+ schemaVersion: INDEX_SCHEMA_VERSION,
263
+ };
264
+ return this._enforceMaxEntries(index);
265
+ });
266
+ }
267
+
268
+ /**
269
+ * @override
270
+ * @param {string} key
271
+ * @returns {Promise<boolean>}
272
+ */
273
+ async has(key) {
274
+ const index = await this._readIndex();
275
+ return key in index.entries;
276
+ }
277
+
278
+ /** @override */
279
+ async keys() {
280
+ const index = await this._readIndex();
281
+ return Object.keys(index.entries);
282
+ }
283
+
284
+ /**
285
+ * @override
286
+ * @param {string} key
287
+ * @returns {Promise<boolean>}
288
+ */
289
+ async delete(key) {
290
+ let existed = false;
291
+ await this._mutateIndex((index) => {
292
+ existed = key in index.entries;
293
+ delete index.entries[key];
294
+ return index;
295
+ });
296
+ return existed;
297
+ }
298
+
299
+ /**
300
+ * Removes the index ref. CAS tree/blob objects are left as loose Git
301
+ * objects and will be pruned by `git gc` (default expiry ~2 weeks).
302
+ * @override
303
+ */
304
+ async clear() {
305
+ try {
306
+ await this._persistence.deleteRef(this._ref);
307
+ } catch {
308
+ // Ref may not exist — that's fine
309
+ }
310
+ }
311
+ }
@@ -15,7 +15,7 @@ import ClockPort from '../../ports/ClockPort.js';
15
15
  export default class ClockAdapter extends ClockPort {
16
16
  /**
17
17
  * @param {object} [options]
18
- * @param {Performance} [options.performanceImpl] - Performance API implementation.
18
+ * @param {{ now(): number }} [options.performanceImpl] - Performance API implementation.
19
19
  * Defaults to `globalThis.performance`.
20
20
  */
21
21
  constructor({ performanceImpl } = {}) {
@@ -28,7 +28,7 @@ export default class ClockAdapter extends ClockPort {
28
28
  * @returns {ClockAdapter}
29
29
  */
30
30
  static node() {
31
- return new ClockAdapter({ performanceImpl: nodePerformance });
31
+ return new ClockAdapter({ performanceImpl: /** @type {{ now(): number }} */ (nodePerformance) });
32
32
  }
33
33
 
34
34
  /**
@@ -46,9 +46,10 @@ async function readStreamBody(bodyStream) {
46
46
  * HttpServerPort request handlers.
47
47
  *
48
48
  * @param {Request} request - Deno Request object
49
- * @returns {Promise<{ method: string, url: string, headers: Object, body: Uint8Array|undefined }>}
49
+ * @returns {Promise<{ method: string, url: string, headers: Record<string, string>, body: Uint8Array|undefined }>}
50
50
  */
51
51
  async function toPlainRequest(request) {
52
+ /** @type {Record<string, string>} */
52
53
  const headers = {};
53
54
  request.headers.forEach((value, key) => {
54
55
  headers[key] = value;
@@ -75,11 +76,11 @@ async function toPlainRequest(request) {
75
76
  /**
76
77
  * Converts a plain-object response from the handler into a Deno Response.
77
78
  *
78
- * @param {{ status?: number, headers?: Object, body?: string|Uint8Array }} plain
79
+ * @param {{ status?: number, headers?: Record<string, string>, body?: string|Uint8Array|null }} plain
79
80
  * @returns {Response}
80
81
  */
81
82
  function toDenoResponse(plain) {
82
- return new Response(plain.body ?? null, {
83
+ return new Response(/** @type {BodyInit | null} */ (plain.body ?? null), {
83
84
  status: plain.status || 200,
84
85
  headers: plain.headers || {},
85
86
  });
@@ -99,7 +100,7 @@ function createHandler(requestHandler, logger) {
99
100
  const plain = await toPlainRequest(request);
100
101
  const response = await requestHandler(plain);
101
102
  return toDenoResponse(response);
102
- } catch (err) {
103
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
103
104
  if (err.status === 413) {
104
105
  const msg = new TextEncoder().encode('Payload Too Large');
105
106
  return new Response(msg, {
@@ -122,7 +123,7 @@ function createHandler(requestHandler, logger) {
122
123
  /**
123
124
  * Gracefully shuts down the Deno HTTP server.
124
125
  *
125
- * @param {object} state - Shared mutable state `{ server }`
126
+ * @param {{ server: *}} state - Shared mutable state `{ server }`
126
127
  * @param {Function} [callback]
127
128
  */
128
129
  function closeImpl(state, callback) {
@@ -139,7 +140,7 @@ function closeImpl(state, callback) {
139
140
  callback();
140
141
  }
141
142
  },
142
- (err) => {
143
+ /** @param {*} err */ (err) => {
143
144
  state.server = null;
144
145
  if (callback) {
145
146
  callback(err);
@@ -151,7 +152,7 @@ function closeImpl(state, callback) {
151
152
  /**
152
153
  * Returns the server's bound address info.
153
154
  *
154
- * @param {object} state - Shared mutable state `{ server }`
155
+ * @param {{ server: * }} state - Shared mutable state `{ server }`
155
156
  * @returns {{ address: string, port: number, family: string }|null}
156
157
  */
157
158
  function addressImpl(state) {
@@ -189,17 +190,27 @@ export default class DenoHttpAdapter extends HttpServerPort {
189
190
  this._logger = logger || noopLogger;
190
191
  }
191
192
 
192
- /** @inheritdoc */
193
+ /**
194
+ * @param {Function} requestHandler
195
+ * @returns {{ listen: Function, close: Function, address: Function }}
196
+ */
193
197
  createServer(requestHandler) {
194
198
  const handler = createHandler(requestHandler, this._logger);
199
+ /** @type {{ server: * }} */
195
200
  const state = { server: null };
196
201
 
197
202
  return {
203
+ /**
204
+ * @param {number} port
205
+ * @param {string|Function} [host]
206
+ * @param {Function} [callback]
207
+ */
198
208
  listen: (port, host, callback) => {
199
209
  const cb = typeof host === 'function' ? host : callback;
200
210
  const hostname = typeof host === 'string' ? host : undefined;
201
211
 
202
212
  try {
213
+ /** @type {*} */ // TODO(ts-cleanup): type Deno.serve options
203
214
  const serveOptions = {
204
215
  port,
205
216
  onListen() {
@@ -212,8 +223,9 @@ export default class DenoHttpAdapter extends HttpServerPort {
212
223
  serveOptions.hostname = hostname;
213
224
  }
214
225
 
226
+ // @ts-expect-error — Deno global is only available in Deno runtime
215
227
  state.server = globalThis.Deno.serve(serveOptions, handler);
216
- } catch (err) {
228
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
217
229
  if (cb) {
218
230
  cb(err);
219
231
  } else {
@@ -221,6 +233,7 @@ export default class DenoHttpAdapter extends HttpServerPort {
221
233
  }
222
234
  }
223
235
  },
236
+ /** @param {Function} [callback] */
224
237
  close: (callback) => {
225
238
  closeImpl(state, callback);
226
239
  },
@@ -45,6 +45,7 @@
45
45
 
46
46
  import { retry } from '@git-stunts/alfred';
47
47
  import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
48
+ import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.js';
48
49
 
49
50
  /**
50
51
  * Transient Git errors that are safe to retry automatically.
@@ -73,9 +74,13 @@ const TRANSIENT_ERROR_PATTERNS = [
73
74
  'connection timed out',
74
75
  ];
75
76
 
77
+ /**
78
+ * @typedef {Error & { details?: { stderr?: string, code?: number }, exitCode?: number, code?: number }} GitError
79
+ */
80
+
76
81
  /**
77
82
  * Determines if an error is transient and safe to retry.
78
- * @param {Error} error - The error to check
83
+ * @param {GitError} error - The error to check
79
84
  * @returns {boolean} True if the error is transient
80
85
  */
81
86
  function isTransientError(error) {
@@ -102,7 +107,7 @@ const DEFAULT_RETRY_OPTIONS = {
102
107
  /**
103
108
  * Extracts the exit code from a Git command error.
104
109
  * Checks multiple possible locations where the exit code may be stored.
105
- * @param {Error} err - The error object
110
+ * @param {GitError} err - The error object
106
111
  * @returns {number|undefined} The exit code if found
107
112
  */
108
113
  function getExitCode(err) {
@@ -120,7 +125,7 @@ async function refExists(execute, ref) {
120
125
  try {
121
126
  await execute({ args: ['show-ref', '--verify', '--quiet', ref] });
122
127
  return true;
123
- } catch (err) {
128
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
124
129
  if (getExitCode(err) === 1) {
125
130
  return false;
126
131
  }
@@ -164,11 +169,6 @@ async function refExists(execute, ref) {
164
169
  * synchronization, and the retry logic handles lock contention gracefully.
165
170
  *
166
171
  * @extends GraphPersistencePort
167
- * @implements {CommitPort}
168
- * @implements {BlobPort}
169
- * @implements {TreePort}
170
- * @implements {RefPort}
171
- * @implements {ConfigPort}
172
172
  * @see {@link GraphPersistencePort} for the abstract interface contract
173
173
  * @see {@link DEFAULT_RETRY_OPTIONS} for retry configuration details
174
174
  *
@@ -198,19 +198,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
198
198
  /**
199
199
  * Creates a new GitGraphAdapter instance.
200
200
  *
201
- * @param {Object} options - Configuration options
202
- * @param {import('@git-stunts/plumbing').default} options.plumbing - The Git plumbing
203
- * instance to use for executing Git commands. Must be initialized with a valid
204
- * repository path.
205
- * @param {import('@git-stunts/alfred').RetryOptions} [options.retryOptions={}] - Custom
206
- * retry options to override the defaults. Useful for tuning retry behavior based
207
- * on deployment environment:
208
- * - `retries` (number): Maximum retry attempts (default: 3)
209
- * - `delay` (number): Initial delay in ms (default: 100)
210
- * - `maxDelay` (number): Maximum delay cap in ms (default: 2000)
211
- * - `backoff` ('exponential'|'linear'|'constant'): Backoff strategy
212
- * - `jitter` ('full'|'decorrelated'|'none'): Jitter strategy
213
- * - `shouldRetry` (function): Custom predicate for retryable errors
201
+ * @param {{ plumbing: *, retryOptions?: Object }} options - Configuration options
214
202
  *
215
203
  * @throws {Error} If plumbing is not provided
216
204
  *
@@ -387,30 +375,13 @@ export default class GitGraphAdapter extends GraphPersistencePort {
387
375
 
388
376
  /**
389
377
  * Validates that a ref is safe to use in git commands.
390
- * Prevents command injection via malicious ref names.
378
+ * Delegates to shared validation in adapterValidation.js.
391
379
  * @param {string} ref - The ref to validate
392
380
  * @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
393
381
  * @private
394
382
  */
395
383
  _validateRef(ref) {
396
- if (!ref || typeof ref !== 'string') {
397
- throw new Error('Ref must be a non-empty string');
398
- }
399
- // Prevent buffer overflow attacks with extremely long refs
400
- if (ref.length > 1024) {
401
- throw new Error(`Ref too long: ${ref.length} chars. Maximum is 1024`);
402
- }
403
- // Prevent git option injection (must check before pattern matching)
404
- if (ref.startsWith('-') || ref.startsWith('--')) {
405
- throw new Error(`Invalid ref: ${ref}. Refs cannot start with - or --. See https://github.com/git-stunts/git-warp#security`);
406
- }
407
- // Allow alphanumeric, ., /, -, _ in names
408
- // Allow ancestry operators: ^ or ~ optionally followed by digits
409
- // Allow range operators: .. between names
410
- const validRefPattern = /^[a-zA-Z0-9._/-]+((~\d*|\^\d*|\.\.[a-zA-Z0-9._/-]+)*)$/;
411
- if (!validRefPattern.test(ref)) {
412
- throw new Error(`Invalid ref format: ${ref}. Only alphanumeric characters, ., /, -, _, ^, ~, and range operators are allowed. See https://github.com/git-stunts/git-warp#ref-validation`);
413
- }
384
+ validateRef(ref);
414
385
  }
415
386
 
416
387
  /**
@@ -447,6 +418,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
447
418
  */
448
419
  async readTree(treeOid) {
449
420
  const oids = await this.readTreeOids(treeOid);
421
+ /** @type {Record<string, Buffer>} */
450
422
  const files = {};
451
423
  // Process sequentially to avoid spawning thousands of concurrent readBlob calls
452
424
  for (const [path, oid] of Object.entries(oids)) {
@@ -468,6 +440,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
468
440
  args: ['ls-tree', '-r', '-z', treeOid]
469
441
  });
470
442
 
443
+ /** @type {Record<string, string>} */
471
444
  const oids = {};
472
445
  // NUL-separated records: "mode type oid\tpath\0"
473
446
  const records = output.split('\0');
@@ -534,7 +507,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
534
507
  args: ['rev-parse', ref]
535
508
  });
536
509
  return oid.trim();
537
- } catch (err) {
510
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
538
511
  if (getExitCode(err) === 1) {
539
512
  return null;
540
513
  }
@@ -557,42 +530,24 @@ export default class GitGraphAdapter extends GraphPersistencePort {
557
530
 
558
531
  /**
559
532
  * Validates that an OID is safe to use in git commands.
533
+ * Delegates to shared validation in adapterValidation.js.
560
534
  * @param {string} oid - The OID to validate
561
535
  * @throws {Error} If OID is invalid
562
536
  * @private
563
537
  */
564
538
  _validateOid(oid) {
565
- if (!oid || typeof oid !== 'string') {
566
- throw new Error('OID must be a non-empty string');
567
- }
568
- if (oid.length > 64) {
569
- throw new Error(`OID too long: ${oid.length} chars. Maximum is 64`);
570
- }
571
- const validOidPattern = /^[0-9a-fA-F]{4,64}$/;
572
- if (!validOidPattern.test(oid)) {
573
- throw new Error(`Invalid OID format: ${oid}`);
574
- }
539
+ validateOid(oid);
575
540
  }
576
541
 
577
542
  /**
578
543
  * Validates that a limit is a safe positive integer.
544
+ * Delegates to shared validation in adapterValidation.js.
579
545
  * @param {number} limit - The limit to validate
580
546
  * @throws {Error} If limit is invalid
581
547
  * @private
582
548
  */
583
549
  _validateLimit(limit) {
584
- if (typeof limit !== 'number' || !Number.isFinite(limit)) {
585
- throw new Error('Limit must be a finite number');
586
- }
587
- if (!Number.isInteger(limit)) {
588
- throw new Error('Limit must be an integer');
589
- }
590
- if (limit <= 0) {
591
- throw new Error('Limit must be a positive integer');
592
- }
593
- if (limit > 10_000_000) {
594
- throw new Error(`Limit too large: ${limit}. Maximum is 10,000,000`);
595
- }
550
+ validateLimit(limit);
596
551
  }
597
552
 
598
553
  /**
@@ -607,7 +562,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
607
562
  try {
608
563
  await this._executeWithRetry({ args: ['cat-file', '-e', sha] });
609
564
  return true;
610
- } catch (err) {
565
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
611
566
  if (getExitCode(err) === 1) {
612
567
  return false;
613
568
  }
@@ -683,7 +638,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
683
638
  args: ['merge-base', '--is-ancestor', potentialAncestor, descendant]
684
639
  });
685
640
  return true; // Exit code 0 means it IS an ancestor
686
- } catch (err) {
641
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
687
642
  if (this._getExitCode(err) === 1) {
688
643
  return false; // Exit code 1 means it is NOT an ancestor
689
644
  }
@@ -705,7 +660,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
705
660
  });
706
661
  // Preserve empty-string values; only drop trailing newline
707
662
  return value.replace(/\n$/, '');
708
- } catch (err) {
663
+ } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
709
664
  if (this._isConfigKeyNotFound(err)) {
710
665
  return null;
711
666
  }
@@ -732,32 +687,19 @@ export default class GitGraphAdapter extends GraphPersistencePort {
732
687
 
733
688
  /**
734
689
  * Validates that a config key is safe to use in git commands.
690
+ * Delegates to shared validation in adapterValidation.js.
735
691
  * @param {string} key - The config key to validate
736
692
  * @throws {Error} If key is invalid
737
693
  * @private
738
694
  */
739
695
  _validateConfigKey(key) {
740
- if (!key || typeof key !== 'string') {
741
- throw new Error('Config key must be a non-empty string');
742
- }
743
- if (key.length > 256) {
744
- throw new Error(`Config key too long: ${key.length} chars. Maximum is 256`);
745
- }
746
- // Prevent git option injection
747
- if (key.startsWith('-')) {
748
- throw new Error(`Invalid config key: ${key}. Keys cannot start with -`);
749
- }
750
- // Allow section.subsection.key format
751
- const validKeyPattern = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
752
- if (!validKeyPattern.test(key)) {
753
- throw new Error(`Invalid config key format: ${key}`);
754
- }
696
+ validateConfigKey(key);
755
697
  }
756
698
 
757
699
  /**
758
700
  * Extracts the exit code from a Git command error.
759
701
  * Delegates to the standalone getExitCode helper.
760
- * @param {Error} err - The error object
702
+ * @param {GitError} err - The error object
761
703
  * @returns {number|undefined} The exit code if found
762
704
  * @private
763
705
  */
@@ -768,7 +710,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
768
710
  /**
769
711
  * Checks if an error indicates a config key was not found.
770
712
  * Exit code 1 from `git config --get` means the key doesn't exist.
771
- * @param {Error} err - The error object
713
+ * @param {GitError} err - The error object
772
714
  * @returns {boolean} True if the error indicates key not found
773
715
  * @private
774
716
  */