@foss.global/codefeed 4.1.0 → 4.3.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.
@@ -0,0 +1,203 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as validation from './forgeinventory.validation.js';
3
+
4
+ /** Response bytes read from an unused answer before the rest is discarded. */
5
+ const maxDiscardedResponseBytes = 64 * 1024;
6
+
7
+ /**
8
+ * Reads and drops at most 64 KiB of a response body that carries nothing the
9
+ * caller uses, so its connection can be reused; a longer or failing body is
10
+ * discarded with its connection.
11
+ */
12
+ export const discardBody = async (bodyArg: AsyncIterable<Uint8Array> | null): Promise<void> => {
13
+ if (!bodyArg) return;
14
+ const chunks = bodyArg[Symbol.asyncIterator]();
15
+ let bytes = 0;
16
+ try {
17
+ while (bytes <= maxDiscardedResponseBytes) {
18
+ const result = await chunks.next();
19
+ if (result.done) return;
20
+ bytes += result.value.byteLength;
21
+ }
22
+ } catch {
23
+ // The status already decided the outcome.
24
+ } finally {
25
+ try { await chunks.return?.(); } catch { /* The connection is discarded. */ }
26
+ }
27
+ };
28
+
29
+ export interface ITransferBoundsOptions {
30
+ /** Wall-clock limit of the whole transfer. */
31
+ limitMs: number;
32
+ /** Longest gap between two chunks of a watched source. */
33
+ idleMs: number;
34
+ /** The code a passed wall-clock or idle limit fails with. */
35
+ timedOutCode: string;
36
+ /** The code a watched source that goes on after the transfer ended fails with. */
37
+ failedCode: string;
38
+ }
39
+
40
+ export interface ITransferBounds {
41
+ /** Aborts once the caller aborts, a limit passes or the transfer ends. */
42
+ signal: AbortSignal;
43
+ /** Ends the transfer: clears its timers and aborts `signal`. Idempotent. */
44
+ end: () => void;
45
+ /**
46
+ * The error the transfer fails with: the reason that ended it (`request-aborted`
47
+ * or the timeout code), else `errorArg` when it is a `ForgeInventoryError`,
48
+ * else `fallbackArg`.
49
+ */
50
+ failure: (errorArg: unknown, fallbackArg: string) => validation.ForgeInventoryError;
51
+ /** Runs one step of the transfer; it rejects as soon as the transfer ends, even if the step does not. */
52
+ run: <T>(stepArg: () => Promise<T>) => Promise<T>;
53
+ /** Re-arms the idle limit, before a step that waits on the network without a watched source. */
54
+ touch: () => void;
55
+ /** Passes chunks on and re-arms the idle limit on each one. */
56
+ watch: (sourceArg: AsyncIterable<Uint8Array>) => AsyncGenerator<Uint8Array, void, undefined>;
57
+ }
58
+
59
+ /**
60
+ * The bounds of one streamed transfer: the caller's signal, a wall-clock limit
61
+ * and an idle limit that `watch` re-arms on every chunk. Whichever ends it
62
+ * first decides the failure code. Every transfer must be ended with `end`.
63
+ */
64
+ export const createTransferBounds = (signalArg: AbortSignal | undefined, optionsArg: ITransferBoundsOptions): ITransferBounds => {
65
+ const controller = new AbortController();
66
+ let reason: string | null = null;
67
+ const stop = (reasonArg: string) => {
68
+ reason ??= reasonArg;
69
+ controller.abort();
70
+ };
71
+ const abort = () => stop('request-aborted');
72
+ if (signalArg?.aborted) abort();
73
+ signalArg?.addEventListener('abort', abort, { once: true });
74
+ const deadline = setTimeout(() => stop(optionsArg.timedOutCode), optionsArg.limitMs);
75
+ let idle: ReturnType<typeof setTimeout> | undefined;
76
+ const rearm = () => {
77
+ clearTimeout(idle);
78
+ if (!controller.signal.aborted) idle = setTimeout(() => stop(optionsArg.timedOutCode), optionsArg.idleMs);
79
+ };
80
+ rearm();
81
+ const end = () => {
82
+ clearTimeout(deadline);
83
+ clearTimeout(idle);
84
+ signalArg?.removeEventListener('abort', abort);
85
+ stop('transfer-ended');
86
+ };
87
+ const failure = (errorArg: unknown, fallbackArg: string): validation.ForgeInventoryError => {
88
+ if (reason !== null && reason !== 'transfer-ended') return new validation.ForgeInventoryError(reason);
89
+ return errorArg instanceof validation.ForgeInventoryError ? errorArg : new validation.ForgeInventoryError(fallbackArg);
90
+ };
91
+ return {
92
+ signal: controller.signal,
93
+ end,
94
+ failure,
95
+ touch: rearm,
96
+ run: async <T>(stepArg: () => Promise<T>): Promise<T> => {
97
+ if (controller.signal.aborted) throw failure(undefined, 'request-aborted');
98
+ let onAbort!: () => void;
99
+ const aborted = new Promise<never>((_resolve, reject) => {
100
+ onAbort = () => reject(failure(undefined, 'request-aborted'));
101
+ controller.signal.addEventListener('abort', onAbort, { once: true });
102
+ });
103
+ try {
104
+ return await Promise.race([stepArg(), aborted]);
105
+ } finally {
106
+ controller.signal.removeEventListener('abort', onAbort);
107
+ }
108
+ },
109
+ watch: async function* (sourceArg: AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array, void, undefined> {
110
+ for await (const chunk of sourceArg) {
111
+ if (controller.signal.aborted) throw failure(undefined, optionsArg.failedCode);
112
+ rearm();
113
+ yield chunk;
114
+ }
115
+ },
116
+ };
117
+ };
118
+
119
+ export interface IHeldBackCodes {
120
+ /** A chunk that is not a `Uint8Array`. */
121
+ invalidChunk: string;
122
+ /** More bytes than the declared size. */
123
+ overrun: string;
124
+ /** Fewer bytes than the declared size. */
125
+ short: string;
126
+ }
127
+
128
+ export interface IHeldBackChecks {
129
+ /** Sees every non-empty chunk in order, before it is passed on. */
130
+ inspect?: (chunkArg: Uint8Array) => void;
131
+ /** Runs once exactly `size` bytes have arrived, before the last chunk is passed on; a rejection keeps it back. */
132
+ complete?: () => void | Promise<void>;
133
+ }
134
+
135
+ /**
136
+ * Passes the bytes of `sourceArg` on while it counts them. The last chunk is
137
+ * held back until the source has ended with exactly `sizeArg` bytes and
138
+ * `checksArg.complete` has passed, so a consumer never receives a complete
139
+ * body that is wrong; otherwise it throws `codesArg.overrun` as soon as the
140
+ * size is exceeded, `codesArg.short` when the source ends early, or what
141
+ * `complete` threw.
142
+ */
143
+ export async function* heldBackChunks(
144
+ sourceArg: AsyncIterable<Uint8Array>, sizeArg: number, codesArg: IHeldBackCodes, checksArg: IHeldBackChecks = {},
145
+ ): AsyncGenerator<Uint8Array, void, undefined> {
146
+ let bytes = 0;
147
+ let held: Uint8Array | null = null;
148
+ for await (const chunk of sourceArg) {
149
+ if (!(chunk instanceof Uint8Array)) throw new validation.ForgeInventoryError(codesArg.invalidChunk);
150
+ if (chunk.byteLength === 0) continue;
151
+ bytes += chunk.byteLength;
152
+ if (bytes > sizeArg) throw new validation.ForgeInventoryError(codesArg.overrun);
153
+ checksArg.inspect?.(chunk);
154
+ if (held) yield held;
155
+ held = chunk;
156
+ }
157
+ if (bytes !== sizeArg) throw new validation.ForgeInventoryError(codesArg.short);
158
+ await checksArg.complete?.();
159
+ if (held) yield held;
160
+ }
161
+
162
+ /**
163
+ * The readable stream of one transfer's bytes. It pulls `chunksArg` with
164
+ * backpressure and ends `boundsArg` and calls `onEndArg` as soon as it is
165
+ * destroyed, also while a pull is pending: ending the bounds aborts the
166
+ * request, which closes its connection, and the pending pull's result is
167
+ * dropped. (`Readable.from` waits for a pending pull before it finishes a
168
+ * destroy, so a stalled body would keep its connection until the idle limit.)
169
+ * The stream errors with what `chunksArg` throws.
170
+ */
171
+ export const createTransferStream = (
172
+ boundsArg: ITransferBounds, chunksArg: AsyncGenerator<Uint8Array, void, undefined>, onEndArg?: () => void,
173
+ ): InstanceType<typeof plugins.stream.Readable> => {
174
+ let pulling = false;
175
+ const stream: InstanceType<typeof plugins.stream.Readable> = new plugins.stream.Readable({
176
+ read() {
177
+ if (pulling) return;
178
+ pulling = true;
179
+ void (async () => {
180
+ try {
181
+ while (!stream.destroyed) {
182
+ const result = await chunksArg.next();
183
+ if (stream.destroyed) return;
184
+ if (result.done) return void stream.push(null);
185
+ if (!stream.push(result.value)) return;
186
+ }
187
+ } catch (error) {
188
+ stream.destroy(error as Error);
189
+ } finally {
190
+ pulling = false;
191
+ }
192
+ })();
193
+ },
194
+ destroy(errorArg, callbackArg) {
195
+ boundsArg.end();
196
+ onEndArg?.();
197
+ // Runs the generator's cleanup once a pending pull has settled; nothing waits for it.
198
+ chunksArg.return(undefined).catch(() => undefined);
199
+ callbackArg(errorArg);
200
+ },
201
+ });
202
+ return stream;
203
+ };
@@ -6,12 +6,17 @@ type TMeasure = plugins.fossInterfaces.data.TForgeSourceMeasure;
6
6
  type IGitCounts = Omit<plugins.fossInterfaces.data.IForgeSourceGitCounts, 'reportedSizeKiB'>;
7
7
  type ILfsCounts = plugins.fossInterfaces.data.IForgeSourceLfsCounts;
8
8
  type IHead = plugins.fossInterfaces.data.IForgeSourceRepositoryHead;
9
+ type IRefDigests = plugins.fossInterfaces.data.IForgeSourceRefDigests;
9
10
 
10
- /** The Git level measure of one repository; `head` is null when the mirror's refs could not be read. */
11
+ /**
12
+ * The Git level measure of one repository; `head` and `refDigests` are null
13
+ * when the mirror's refs could not be read.
14
+ */
11
15
  export interface IGiteaMirrorMeasure {
12
16
  git: IGitCounts;
13
17
  lfs: ILfsCounts;
14
18
  head: IHead | null;
19
+ refDigests: IRefDigests | null;
15
20
  }
16
21
 
17
22
  export interface IGiteaMirrorSource {
@@ -207,7 +212,7 @@ export const measureGiteaMirror = async (sourceArg: IGiteaMirrorSource): Promise
207
212
  release = await reserveMirrorScratch(sourceArg.workDir, sourceArg.reportedSizeKiB, signal);
208
213
  } catch (error) {
209
214
  const failure = stepFailure(error, 'git-workdir-failed', signal);
210
- return { git: failedGit(failure), lfs: failedLfs(failure), head: null };
215
+ return { git: failedGit(failure), lfs: failedLfs(failure), head: null, refDigests: null };
211
216
  }
212
217
  try {
213
218
  return await measureReservedMirror(sourceArg);
@@ -223,7 +228,7 @@ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<IGi
223
228
  try {
224
229
  directory = await plugins.fs.mkdtemp(plugins.path.join(sourceArg.workDir, 'mirror-'));
225
230
  } catch {
226
- return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed'), head: null };
231
+ return { git: failedGit('git-workdir-failed'), lfs: failedLfs('git-workdir-failed'), head: null, refDigests: null };
227
232
  }
228
233
  const manager = new GitManager({ dataDir: directory });
229
234
  // The engine serves this measurement alone; stopping it ends the ref, object
@@ -245,16 +250,16 @@ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<IGi
245
250
  }, { signal });
246
251
  } catch (error) {
247
252
  const failure = stepFailure(error, 'git-mirror-failed', signal);
248
- return { git: failedGit(failure), lfs: failedLfs(failure), head: null };
253
+ return { git: failedGit(failure), lfs: failedLfs(failure), head: null, refDigests: null };
249
254
  }
250
- const { git, head } = await measureRefsAndObjects(manager, selector, signal);
255
+ const { git, head, refDigests } = await measureRefsAndObjects(manager, selector, signal);
251
256
  let pointers: Array<{ oid: string; sizeBytes: number }>;
252
257
  try {
253
258
  pointers = await manager.listRepositoryLfsPointers(selector);
254
259
  } catch (error) {
255
- return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)), head };
260
+ return { git, lfs: failedLfs(stepFailure(error, 'lfs-pointer-scan-failed', signal)), head, refDigests };
256
261
  }
257
- return { git, lfs: await giteaLfsPresence(sourceArg, pointers), head };
262
+ return { git, lfs: await giteaLfsPresence(sourceArg, pointers), head, refDigests };
258
263
  } finally {
259
264
  signal?.removeEventListener('abort', abort);
260
265
  await manager.stop().catch(() => undefined);
@@ -262,11 +267,39 @@ const measureReservedMirror = async (sourceArg: IGiteaMirrorSource): Promise<IGi
262
267
  }
263
268
  };
264
269
 
270
+ /** The ref namespaces an import carries, each recorded with its own digest. */
271
+ const refDigestPrefixes = { heads: 'refs/heads/', tags: 'refs/tags/', notes: 'refs/notes/' } as const;
272
+
273
+ /**
274
+ * Reads GitManager's ref inventory digest and ref count of each namespace in
275
+ * `refDigestPrefixes`: one `listRefs` page of a single ref per prefix, whose
276
+ * digest and count cover the complete selection. Resolves with null when any
277
+ * read fails, never with a partial set.
278
+ */
279
+ export const readRefDigests = async (
280
+ managerArg: Pick<plugins.TGitManager, 'listRefs'>,
281
+ selectorArg: { org: string; repo: string },
282
+ ): Promise<IRefDigests | null> => {
283
+ const read = async (prefixArg: string): Promise<IRefDigests['heads'] | null> => {
284
+ const page = await managerArg.listRefs({ ...selectorArg, prefixes: [prefixArg], limit: 1 });
285
+ // Only a cursor can be stale, and a first page sends none.
286
+ return page.status === 'page' ? { inventoryDigest: page.inventoryDigest, refCount: page.refCount } : null;
287
+ };
288
+ try {
289
+ const heads = await read(refDigestPrefixes.heads);
290
+ const tags = await read(refDigestPrefixes.tags);
291
+ const notes = await read(refDigestPrefixes.notes);
292
+ return heads && tags && notes ? { heads, tags, notes } : null;
293
+ } catch {
294
+ return null;
295
+ }
296
+ };
297
+
265
298
  const measureRefsAndObjects = async (
266
299
  managerArg: plugins.TGitManager,
267
300
  selectorArg: { org: string; repo: string },
268
301
  signalArg?: AbortSignal,
269
- ): Promise<Pick<IGiteaMirrorMeasure, 'git' | 'head'>> => {
302
+ ): Promise<Pick<IGiteaMirrorMeasure, 'git' | 'head' | 'refDigests'>> => {
270
303
  const counts = { branches: 0, tags: 0, pullRequestRefs: 0, otherRefs: 0 };
271
304
  let head: IHead | null = null;
272
305
  let refsFailure: string | null = null;
@@ -291,6 +324,8 @@ const measureRefsAndObjects = async (
291
324
  refsFailure = stepFailure(error, 'git-refs-failed', signalArg);
292
325
  }
293
326
  const refs = (valueArg: number) => refsFailure === null ? complete(valueArg) : partial(0, refsFailure);
327
+ // Nothing writes to the private mirror, so the digests describe the refs just counted.
328
+ const refDigests = refsFailure === null ? await readRefDigests(managerArg, selectorArg) : null;
294
329
  let objects: TMeasure;
295
330
  let objectBytes: TMeasure;
296
331
  try {
@@ -306,6 +341,7 @@ const measureRefsAndObjects = async (
306
341
  objects, objectBytes,
307
342
  },
308
343
  head,
344
+ refDigests,
309
345
  };
310
346
  };
311
347