@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.
- package/README.md +6 -3
- package/SECURITY.md +89 -1
- package/bin/warp-graph.js +574 -208
- package/index.d.ts +55 -0
- package/index.js +4 -0
- package/package.json +8 -4
- package/src/domain/WarpGraph.js +334 -161
- package/src/domain/crdt/LWW.js +1 -1
- package/src/domain/crdt/ORSet.js +10 -6
- package/src/domain/crdt/VersionVector.js +5 -1
- package/src/domain/errors/EmptyMessageError.js +2 -4
- package/src/domain/errors/ForkError.js +4 -0
- package/src/domain/errors/IndexError.js +4 -0
- package/src/domain/errors/OperationAbortedError.js +4 -0
- package/src/domain/errors/QueryError.js +4 -0
- package/src/domain/errors/SchemaUnsupportedError.js +4 -0
- package/src/domain/errors/ShardCorruptionError.js +2 -6
- package/src/domain/errors/ShardLoadError.js +2 -6
- package/src/domain/errors/ShardValidationError.js +2 -7
- package/src/domain/errors/StorageError.js +2 -6
- package/src/domain/errors/SyncError.js +4 -0
- package/src/domain/errors/TraversalError.js +4 -0
- package/src/domain/errors/WarpError.js +2 -4
- package/src/domain/errors/WormholeError.js +4 -0
- package/src/domain/services/AnchorMessageCodec.js +1 -4
- package/src/domain/services/BitmapIndexBuilder.js +10 -6
- package/src/domain/services/BitmapIndexReader.js +27 -21
- package/src/domain/services/BoundaryTransitionRecord.js +22 -15
- package/src/domain/services/CheckpointMessageCodec.js +1 -7
- package/src/domain/services/CheckpointSerializerV5.js +20 -19
- package/src/domain/services/CheckpointService.js +18 -18
- package/src/domain/services/CommitDagTraversalService.js +13 -1
- package/src/domain/services/DagPathFinding.js +40 -18
- package/src/domain/services/DagTopology.js +7 -6
- package/src/domain/services/DagTraversal.js +5 -3
- package/src/domain/services/Frontier.js +7 -6
- package/src/domain/services/HealthCheckService.js +15 -14
- package/src/domain/services/HookInstaller.js +64 -13
- package/src/domain/services/HttpSyncServer.js +88 -19
- package/src/domain/services/IndexRebuildService.js +12 -12
- package/src/domain/services/IndexStalenessChecker.js +13 -6
- package/src/domain/services/JoinReducer.js +28 -27
- package/src/domain/services/LogicalTraversal.js +7 -6
- package/src/domain/services/MessageCodecInternal.js +2 -0
- package/src/domain/services/ObserverView.js +6 -6
- package/src/domain/services/PatchBuilderV2.js +9 -9
- package/src/domain/services/PatchMessageCodec.js +1 -7
- package/src/domain/services/ProvenanceIndex.js +6 -8
- package/src/domain/services/ProvenancePayload.js +1 -2
- package/src/domain/services/QueryBuilder.js +29 -23
- package/src/domain/services/StateDiff.js +7 -7
- package/src/domain/services/StateSerializerV5.js +8 -6
- package/src/domain/services/StreamingBitmapIndexBuilder.js +29 -23
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/domain/services/SyncProtocol.js +23 -26
- package/src/domain/services/TemporalQuery.js +4 -3
- package/src/domain/services/TranslationCost.js +4 -4
- package/src/domain/services/WormholeService.js +19 -15
- package/src/domain/types/TickReceipt.js +10 -6
- package/src/domain/types/WarpTypesV2.js +2 -3
- package/src/domain/utils/CachedValue.js +1 -1
- package/src/domain/utils/LRUCache.js +3 -3
- package/src/domain/utils/MinHeap.js +2 -2
- package/src/domain/utils/RefLayout.js +19 -0
- package/src/domain/utils/WriterId.js +2 -2
- package/src/domain/utils/defaultCodec.js +9 -2
- package/src/domain/utils/defaultCrypto.js +36 -0
- package/src/domain/utils/roaring.js +5 -5
- package/src/domain/utils/seekCacheKey.js +32 -0
- package/src/domain/warp/PatchSession.js +3 -3
- package/src/domain/warp/Writer.js +2 -2
- package/src/infrastructure/adapters/BunHttpAdapter.js +21 -8
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +311 -0
- package/src/infrastructure/adapters/ClockAdapter.js +2 -2
- package/src/infrastructure/adapters/DenoHttpAdapter.js +22 -9
- package/src/infrastructure/adapters/GitGraphAdapter.js +25 -83
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/NodeCryptoAdapter.js +16 -3
- package/src/infrastructure/adapters/NodeHttpAdapter.js +33 -11
- package/src/infrastructure/adapters/WebCryptoAdapter.js +21 -11
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/infrastructure/codecs/CborCodec.js +16 -8
- package/src/ports/BlobPort.js +2 -2
- package/src/ports/CodecPort.js +2 -2
- package/src/ports/CommitPort.js +8 -21
- package/src/ports/ConfigPort.js +3 -3
- package/src/ports/CryptoPort.js +7 -7
- package/src/ports/GraphPersistencePort.js +12 -14
- package/src/ports/HttpServerPort.js +1 -5
- package/src/ports/IndexStoragePort.js +1 -0
- package/src/ports/LoggerPort.js +9 -9
- package/src/ports/RefPort.js +5 -5
- package/src/ports/SeekCachePort.js +73 -0
- package/src/ports/TreePort.js +3 -3
- package/src/visualization/layouts/converters.js +14 -7
- package/src/visualization/layouts/elkAdapter.js +17 -4
- package/src/visualization/layouts/elkLayout.js +23 -7
- package/src/visualization/layouts/index.js +3 -3
- package/src/visualization/renderers/ascii/check.js +30 -17
- package/src/visualization/renderers/ascii/graph.js +92 -1
- package/src/visualization/renderers/ascii/history.js +28 -26
- package/src/visualization/renderers/ascii/info.js +9 -7
- package/src/visualization/renderers/ascii/materialize.js +20 -16
- package/src/visualization/renderers/ascii/opSummary.js +15 -7
- package/src/visualization/renderers/ascii/path.js +1 -1
- package/src/visualization/renderers/ascii/seek.js +187 -23
- package/src/visualization/renderers/ascii/table.js +1 -1
- 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 {
|
|
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:
|
|
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?:
|
|
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 {
|
|
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 {
|
|
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
|
-
/**
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
713
|
+
* @param {GitError} err - The error object
|
|
772
714
|
* @returns {boolean} True if the error indicates key not found
|
|
773
715
|
* @private
|
|
774
716
|
*/
|