@git-stunts/git-warp 10.1.2 → 10.4.2
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 +31 -4
- package/bin/warp-graph.js +1242 -59
- package/index.d.ts +31 -0
- package/index.js +4 -0
- package/package.json +13 -3
- package/src/domain/WarpGraph.js +487 -140
- 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 +15 -14
- 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/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 +106 -15
- 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/parseCursorBlob.js +51 -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 +16 -27
- 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/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 +24 -11
- 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 +122 -16
- package/src/visualization/renderers/ascii/history.js +29 -90
- package/src/visualization/renderers/ascii/index.js +1 -1
- 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 +81 -0
- package/src/visualization/renderers/ascii/path.js +1 -1
- package/src/visualization/renderers/ascii/seek.js +344 -0
- 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
|
},
|
|
@@ -73,9 +73,13 @@ const TRANSIENT_ERROR_PATTERNS = [
|
|
|
73
73
|
'connection timed out',
|
|
74
74
|
];
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {Error & { details?: { stderr?: string, code?: number }, exitCode?: number, code?: number }} GitError
|
|
78
|
+
*/
|
|
79
|
+
|
|
76
80
|
/**
|
|
77
81
|
* Determines if an error is transient and safe to retry.
|
|
78
|
-
* @param {
|
|
82
|
+
* @param {GitError} error - The error to check
|
|
79
83
|
* @returns {boolean} True if the error is transient
|
|
80
84
|
*/
|
|
81
85
|
function isTransientError(error) {
|
|
@@ -102,7 +106,7 @@ const DEFAULT_RETRY_OPTIONS = {
|
|
|
102
106
|
/**
|
|
103
107
|
* Extracts the exit code from a Git command error.
|
|
104
108
|
* Checks multiple possible locations where the exit code may be stored.
|
|
105
|
-
* @param {
|
|
109
|
+
* @param {GitError} err - The error object
|
|
106
110
|
* @returns {number|undefined} The exit code if found
|
|
107
111
|
*/
|
|
108
112
|
function getExitCode(err) {
|
|
@@ -120,7 +124,7 @@ async function refExists(execute, ref) {
|
|
|
120
124
|
try {
|
|
121
125
|
await execute({ args: ['show-ref', '--verify', '--quiet', ref] });
|
|
122
126
|
return true;
|
|
123
|
-
} catch (err) {
|
|
127
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
|
|
124
128
|
if (getExitCode(err) === 1) {
|
|
125
129
|
return false;
|
|
126
130
|
}
|
|
@@ -164,11 +168,6 @@ async function refExists(execute, ref) {
|
|
|
164
168
|
* synchronization, and the retry logic handles lock contention gracefully.
|
|
165
169
|
*
|
|
166
170
|
* @extends GraphPersistencePort
|
|
167
|
-
* @implements {CommitPort}
|
|
168
|
-
* @implements {BlobPort}
|
|
169
|
-
* @implements {TreePort}
|
|
170
|
-
* @implements {RefPort}
|
|
171
|
-
* @implements {ConfigPort}
|
|
172
171
|
* @see {@link GraphPersistencePort} for the abstract interface contract
|
|
173
172
|
* @see {@link DEFAULT_RETRY_OPTIONS} for retry configuration details
|
|
174
173
|
*
|
|
@@ -198,19 +197,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
198
197
|
/**
|
|
199
198
|
* Creates a new GitGraphAdapter instance.
|
|
200
199
|
*
|
|
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
|
|
200
|
+
* @param {{ plumbing: *, retryOptions?: Object }} options - Configuration options
|
|
214
201
|
*
|
|
215
202
|
* @throws {Error} If plumbing is not provided
|
|
216
203
|
*
|
|
@@ -447,6 +434,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
447
434
|
*/
|
|
448
435
|
async readTree(treeOid) {
|
|
449
436
|
const oids = await this.readTreeOids(treeOid);
|
|
437
|
+
/** @type {Record<string, Buffer>} */
|
|
450
438
|
const files = {};
|
|
451
439
|
// Process sequentially to avoid spawning thousands of concurrent readBlob calls
|
|
452
440
|
for (const [path, oid] of Object.entries(oids)) {
|
|
@@ -468,6 +456,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
468
456
|
args: ['ls-tree', '-r', '-z', treeOid]
|
|
469
457
|
});
|
|
470
458
|
|
|
459
|
+
/** @type {Record<string, string>} */
|
|
471
460
|
const oids = {};
|
|
472
461
|
// NUL-separated records: "mode type oid\tpath\0"
|
|
473
462
|
const records = output.split('\0');
|
|
@@ -534,7 +523,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
534
523
|
args: ['rev-parse', ref]
|
|
535
524
|
});
|
|
536
525
|
return oid.trim();
|
|
537
|
-
} catch (err) {
|
|
526
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
|
|
538
527
|
if (getExitCode(err) === 1) {
|
|
539
528
|
return null;
|
|
540
529
|
}
|
|
@@ -607,7 +596,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
607
596
|
try {
|
|
608
597
|
await this._executeWithRetry({ args: ['cat-file', '-e', sha] });
|
|
609
598
|
return true;
|
|
610
|
-
} catch (err) {
|
|
599
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
|
|
611
600
|
if (getExitCode(err) === 1) {
|
|
612
601
|
return false;
|
|
613
602
|
}
|
|
@@ -683,7 +672,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
683
672
|
args: ['merge-base', '--is-ancestor', potentialAncestor, descendant]
|
|
684
673
|
});
|
|
685
674
|
return true; // Exit code 0 means it IS an ancestor
|
|
686
|
-
} catch (err) {
|
|
675
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
|
|
687
676
|
if (this._getExitCode(err) === 1) {
|
|
688
677
|
return false; // Exit code 1 means it is NOT an ancestor
|
|
689
678
|
}
|
|
@@ -705,7 +694,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
705
694
|
});
|
|
706
695
|
// Preserve empty-string values; only drop trailing newline
|
|
707
696
|
return value.replace(/\n$/, '');
|
|
708
|
-
} catch (err) {
|
|
697
|
+
} catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error
|
|
709
698
|
if (this._isConfigKeyNotFound(err)) {
|
|
710
699
|
return null;
|
|
711
700
|
}
|
|
@@ -757,7 +746,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
757
746
|
/**
|
|
758
747
|
* Extracts the exit code from a Git command error.
|
|
759
748
|
* Delegates to the standalone getExitCode helper.
|
|
760
|
-
* @param {
|
|
749
|
+
* @param {GitError} err - The error object
|
|
761
750
|
* @returns {number|undefined} The exit code if found
|
|
762
751
|
* @private
|
|
763
752
|
*/
|
|
@@ -768,7 +757,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
768
757
|
/**
|
|
769
758
|
* Checks if an error indicates a config key was not found.
|
|
770
759
|
* Exit code 1 from `git config --get` means the key doesn't exist.
|
|
771
|
-
* @param {
|
|
760
|
+
* @param {GitError} err - The error object
|
|
772
761
|
* @returns {boolean} True if the error indicates key not found
|
|
773
762
|
* @private
|
|
774
763
|
*/
|
|
@@ -13,19 +13,32 @@ import {
|
|
|
13
13
|
* @extends CryptoPort
|
|
14
14
|
*/
|
|
15
15
|
export default class NodeCryptoAdapter extends CryptoPort {
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} algorithm
|
|
18
|
+
* @param {string|Buffer|Uint8Array} data
|
|
19
|
+
* @returns {Promise<string>}
|
|
20
|
+
*/
|
|
17
21
|
// eslint-disable-next-line @typescript-eslint/require-await -- async ensures sync throws become rejected promises
|
|
18
22
|
async hash(algorithm, data) {
|
|
19
23
|
return createHash(algorithm).update(data).digest('hex');
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} algorithm
|
|
28
|
+
* @param {string|Buffer|Uint8Array} key
|
|
29
|
+
* @param {string|Buffer|Uint8Array} data
|
|
30
|
+
* @returns {Promise<Buffer>}
|
|
31
|
+
*/
|
|
23
32
|
// eslint-disable-next-line @typescript-eslint/require-await -- async ensures sync throws become rejected promises
|
|
24
33
|
async hmac(algorithm, key, data) {
|
|
25
34
|
return createHmac(algorithm, key).update(data).digest();
|
|
26
35
|
}
|
|
27
36
|
|
|
28
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* @param {Buffer|Uint8Array} a
|
|
39
|
+
* @param {Buffer|Uint8Array} b
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
29
42
|
timingSafeEqual(a, b) {
|
|
30
43
|
return nodeTimingSafeEqual(a, b);
|
|
31
44
|
}
|
|
@@ -7,6 +7,9 @@ const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
|
7
7
|
/**
|
|
8
8
|
* Collects the request body and dispatches to the handler, returning
|
|
9
9
|
* a 500 response if the handler throws.
|
|
10
|
+
* @param {import('node:http').IncomingMessage} req
|
|
11
|
+
* @param {import('node:http').ServerResponse} res
|
|
12
|
+
* @param {{ handler: Function, logger: { error: Function } }} options
|
|
10
13
|
*/
|
|
11
14
|
async function dispatch(req, res, { handler, logger }) {
|
|
12
15
|
try {
|
|
@@ -60,33 +63,52 @@ export default class NodeHttpAdapter extends HttpServerPort {
|
|
|
60
63
|
this._logger = logger || noopLogger;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* @param {Function} requestHandler
|
|
68
|
+
* @returns {{ listen: Function, close: Function, address: Function }}
|
|
69
|
+
*/
|
|
64
70
|
createServer(requestHandler) {
|
|
65
71
|
const logger = this._logger;
|
|
66
72
|
const server = createServer((req, res) => {
|
|
67
|
-
dispatch(req, res, { handler: requestHandler, logger }).catch(
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
dispatch(req, res, { handler: requestHandler, logger }).catch(
|
|
74
|
+
/** @param {*} err */ (err) => {
|
|
75
|
+
logger.error('[NodeHttpAdapter] unhandled dispatch error:', err);
|
|
76
|
+
});
|
|
70
77
|
});
|
|
71
78
|
|
|
72
79
|
return {
|
|
80
|
+
/**
|
|
81
|
+
* @param {number} port
|
|
82
|
+
* @param {string|Function} [host]
|
|
83
|
+
* @param {Function} [callback]
|
|
84
|
+
*/
|
|
73
85
|
listen(port, host, callback) {
|
|
74
86
|
const cb = typeof host === 'function' ? host : callback;
|
|
75
87
|
const bindHost = typeof host === 'string' ? host : undefined;
|
|
88
|
+
/** @param {*} err */
|
|
76
89
|
const onError = (err) => {
|
|
77
90
|
if (cb) {
|
|
78
91
|
cb(err);
|
|
79
92
|
}
|
|
80
93
|
};
|
|
81
94
|
server.once('error', onError);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
if (bindHost !== undefined) {
|
|
96
|
+
server.listen(port, bindHost, () => {
|
|
97
|
+
server.removeListener('error', onError);
|
|
98
|
+
if (cb) {
|
|
99
|
+
cb(null);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
server.listen(port, () => {
|
|
104
|
+
server.removeListener('error', onError);
|
|
105
|
+
if (cb) {
|
|
106
|
+
cb(null);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
89
110
|
},
|
|
111
|
+
/** @param {((err?: Error) => void)} [callback] */
|
|
90
112
|
close(callback) {
|
|
91
113
|
server.close(callback);
|
|
92
114
|
},
|