@flowscripter/pluggable-io-framework 1.1.5 → 1.2.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 +78 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/src/ConcurrencyLimiter.d.ts +33 -0
- package/dist/src/ConcurrencyLimiter.d.ts.map +1 -0
- package/dist/src/ConcurrencyLimiter.js +161 -0
- package/dist/src/copyMove.d.ts +25 -8
- package/dist/src/copyMove.d.ts.map +1 -1
- package/dist/src/copyMove.js +233 -58
- package/dist/src/withRetry.d.ts +13 -0
- package/dist/src/withRetry.d.ts.map +1 -0
- package/dist/src/withRetry.js +26 -0
- package/index.ts +2 -0
- package/package.json +2 -2
- package/src/ConcurrencyLimiter.ts +178 -0
- package/src/copyMove.ts +347 -79
- package/src/withRetry.ts +33 -0
package/src/copyMove.ts
CHANGED
|
@@ -4,10 +4,18 @@ import {
|
|
|
4
4
|
type ChunkConverter,
|
|
5
5
|
type ChunkRef,
|
|
6
6
|
type IOProvider,
|
|
7
|
+
type ItemProperties,
|
|
7
8
|
type Part,
|
|
9
|
+
type PartSizeConstraints,
|
|
8
10
|
type TelemetryHooks,
|
|
9
11
|
} from "@flowscripter/pluggable-io-framework-api";
|
|
10
12
|
import { chunkLength } from "./chunkLength.ts";
|
|
13
|
+
import {
|
|
14
|
+
type ConcurrencyLimiter,
|
|
15
|
+
defaultConcurrencyLimiter,
|
|
16
|
+
mapAsyncIterableConcurrently,
|
|
17
|
+
} from "./ConcurrencyLimiter.ts";
|
|
18
|
+
import { withRetry, type RetryOptions } from "./withRetry.ts";
|
|
11
19
|
|
|
12
20
|
export interface TransferOptions {
|
|
13
21
|
readonly telemetry?: TelemetryHooks;
|
|
@@ -20,9 +28,85 @@ export interface TransferOptions {
|
|
|
20
28
|
* FFI-capable converter supplied by a runtime-specific package.
|
|
21
29
|
*/
|
|
22
30
|
readonly chunkConverter?: ChunkConverter;
|
|
31
|
+
/**
|
|
32
|
+
* Bounds concurrent multipart parts and recursive-copy/move entries.
|
|
33
|
+
* Defaults to the shared {@link defaultConcurrencyLimiter} - so two
|
|
34
|
+
* `copy()`/`move()` calls in the same process share one cap unless a
|
|
35
|
+
* caller passes its own isolated instance.
|
|
36
|
+
*/
|
|
37
|
+
readonly concurrencyLimiter?: ConcurrencyLimiter;
|
|
38
|
+
/** Retry policy for the non-direct transfer path. Defaults to `{ maxRetries: 3 }`. */
|
|
39
|
+
readonly retry?: RetryOptions;
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
const DEFAULT_MULTIPART_THRESHOLD = 64 * 1024 * 1024;
|
|
43
|
+
const DEFAULT_PART_SIZE = 8 * 1024 * 1024;
|
|
44
|
+
const DEFAULT_RETRY: RetryOptions = { maxRetries: 3 };
|
|
45
|
+
|
|
46
|
+
const UNCONSTRAINED_PART_SIZE: PartSizeConstraints = {
|
|
47
|
+
minPartSize: 0,
|
|
48
|
+
maxPartSize: Infinity,
|
|
49
|
+
maxParts: Infinity,
|
|
50
|
+
defaultPartSize: DEFAULT_PART_SIZE,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Wraps `hooks` so any `onProgress` event passing through gets `parentOperationId` filled in, unless the emitter already set one (nested hierarchy). */
|
|
54
|
+
function withParentOperationId(
|
|
55
|
+
hooks: TelemetryHooks | undefined,
|
|
56
|
+
parentOperationId: string | undefined,
|
|
57
|
+
): TelemetryHooks {
|
|
58
|
+
if (!hooks || parentOperationId === undefined) {
|
|
59
|
+
return hooks ?? {};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
...hooks,
|
|
63
|
+
onProgress: hooks.onProgress
|
|
64
|
+
? (event) => hooks.onProgress?.({ parentOperationId, ...event })
|
|
65
|
+
: undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function joinPath(base: string, relative: string): string {
|
|
70
|
+
if (relative === "") return base;
|
|
71
|
+
if (base === "") return relative;
|
|
72
|
+
return `${base.replace(/\/+$/, "")}/${relative}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reconciles `source`'s and `sink`'s {@link PartSizeConstraints} for a
|
|
77
|
+
* transfer of `totalSize` bytes into a single part size satisfying both.
|
|
78
|
+
* Returns `undefined` when the bounds are mutually infeasible (source's
|
|
79
|
+
* minimum exceeds sink's maximum) - the caller should fall back to plain
|
|
80
|
+
* streaming rather than throwing.
|
|
81
|
+
*/
|
|
82
|
+
function negotiatePartSize(
|
|
83
|
+
source: IOProvider,
|
|
84
|
+
sink: IOProvider,
|
|
85
|
+
totalSize: number,
|
|
86
|
+
): number | undefined {
|
|
87
|
+
const sourceConstraints = source.getPartSizeConstraints?.(totalSize) ?? UNCONSTRAINED_PART_SIZE;
|
|
88
|
+
const sinkConstraints = sink.getPartSizeConstraints?.(totalSize) ?? UNCONSTRAINED_PART_SIZE;
|
|
89
|
+
const minPartSize = Math.max(sourceConstraints.minPartSize, sinkConstraints.minPartSize);
|
|
90
|
+
const maxPartSize = Math.min(sourceConstraints.maxPartSize, sinkConstraints.maxPartSize);
|
|
91
|
+
const maxParts = Math.min(sourceConstraints.maxParts, sinkConstraints.maxParts);
|
|
92
|
+
if (minPartSize > maxPartSize) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const byMaxParts = Math.ceil(totalSize / maxParts);
|
|
96
|
+
const candidate = Math.max(
|
|
97
|
+
minPartSize,
|
|
98
|
+
sourceConstraints.defaultPartSize,
|
|
99
|
+
sinkConstraints.defaultPartSize,
|
|
100
|
+
byMaxParts,
|
|
101
|
+
);
|
|
102
|
+
return Math.min(candidate, maxPartSize);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function canUseMultipart(source: IOProvider, sink: IOProvider): boolean {
|
|
106
|
+
return (
|
|
107
|
+
typeof source.getMultipartReader === "function" && typeof sink.getMultipartWriter === "function"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
26
110
|
|
|
27
111
|
async function streamingTransfer(
|
|
28
112
|
source: IOProvider,
|
|
@@ -30,9 +114,11 @@ async function streamingTransfer(
|
|
|
30
114
|
sink: IOProvider,
|
|
31
115
|
destPath: string,
|
|
32
116
|
operationId: string,
|
|
117
|
+
parentOperationId: string | undefined,
|
|
33
118
|
options: TransferOptions,
|
|
34
119
|
totalBytes: number | undefined,
|
|
35
120
|
): Promise<void> {
|
|
121
|
+
const hooks = withParentOperationId(options.telemetry, parentOperationId);
|
|
36
122
|
const readable = await source.getReadableStream(sourcePath);
|
|
37
123
|
const writable = await sink.getWritableStream(destPath);
|
|
38
124
|
// The source/sink kind mismatch is decided ONCE here, not per chunk.
|
|
@@ -51,7 +137,7 @@ async function streamingTransfer(
|
|
|
51
137
|
if (done) break;
|
|
52
138
|
await writer.write(value);
|
|
53
139
|
bytesProcessed += chunkLength(value);
|
|
54
|
-
|
|
140
|
+
hooks.onProgress?.({ operationId, type: "copy", bytesProcessed, totalBytes });
|
|
55
141
|
}
|
|
56
142
|
await writer.close();
|
|
57
143
|
} catch (error) {
|
|
@@ -66,67 +152,270 @@ async function multipartTransfer(
|
|
|
66
152
|
sink: IOProvider,
|
|
67
153
|
destPath: string,
|
|
68
154
|
operationId: string,
|
|
155
|
+
parentOperationId: string | undefined,
|
|
156
|
+
partSize: number,
|
|
157
|
+
limiter: ConcurrencyLimiter,
|
|
69
158
|
options: TransferOptions,
|
|
70
159
|
totalBytes: number | undefined,
|
|
71
160
|
): Promise<void> {
|
|
161
|
+
const hooks = withParentOperationId(options.telemetry, parentOperationId);
|
|
72
162
|
let bytesProcessed = 0;
|
|
73
|
-
|
|
74
|
-
async function
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
163
|
+
|
|
164
|
+
async function transferPart(sourcePart: Part): Promise<Part> {
|
|
165
|
+
const partOperationId = crypto.randomUUID();
|
|
166
|
+
// Kind mismatch decided once per part, not once per chunk within it.
|
|
167
|
+
const adaptedStream = adaptReadableStream(
|
|
168
|
+
sourcePart.stream as ReadableStream<ChunkRef>,
|
|
169
|
+
sourcePart.kind,
|
|
170
|
+
sink.kind,
|
|
171
|
+
options.chunkConverter ?? identityChunkConverter,
|
|
172
|
+
);
|
|
173
|
+
const reader = adaptedStream.getReader();
|
|
174
|
+
const chunks: ChunkRef[] = [];
|
|
175
|
+
let partBytesProcessed = 0;
|
|
176
|
+
for (;;) {
|
|
177
|
+
const { done, value } = await reader.read();
|
|
178
|
+
if (done) break;
|
|
179
|
+
chunks.push(value);
|
|
180
|
+
const length = chunkLength(value);
|
|
181
|
+
partBytesProcessed += length;
|
|
182
|
+
bytesProcessed += length;
|
|
183
|
+
hooks.onProgress?.({
|
|
184
|
+
operationId: partOperationId,
|
|
185
|
+
parentOperationId: operationId,
|
|
186
|
+
type: "copy",
|
|
187
|
+
bytesProcessed: partBytesProcessed,
|
|
188
|
+
});
|
|
189
|
+
hooks.onProgress?.({ operationId, type: "copy", bytesProcessed, totalBytes });
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
index: sourcePart.index,
|
|
193
|
+
offset: sourcePart.offset,
|
|
194
|
+
kind: sink.kind,
|
|
195
|
+
stream: new ReadableStream<ChunkRef>({
|
|
196
|
+
start(controller) {
|
|
197
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
198
|
+
controller.close();
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
complete: () => sourcePart.complete(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const writer = sink.getMultipartWriter(destPath, partSize);
|
|
206
|
+
const transferredParts = mapAsyncIterableConcurrently(
|
|
207
|
+
source.getMultipartReader(sourcePath, partSize),
|
|
208
|
+
transferPart,
|
|
209
|
+
limiter,
|
|
210
|
+
);
|
|
211
|
+
await writer.write(transferredParts);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function copySingle(
|
|
215
|
+
source: IOProvider,
|
|
216
|
+
sourcePath: string,
|
|
217
|
+
sink: IOProvider,
|
|
218
|
+
destPath: string,
|
|
219
|
+
options: TransferOptions,
|
|
220
|
+
parentOperationId: string | undefined,
|
|
221
|
+
properties: ItemProperties,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const operationId = crypto.randomUUID();
|
|
224
|
+
|
|
225
|
+
if (source.canDirectTransfer?.(sink) && source.directCopy) {
|
|
226
|
+
await source.directCopy(sourcePath, destPath, {
|
|
227
|
+
operationId,
|
|
228
|
+
hooks: withParentOperationId(options.telemetry, parentOperationId),
|
|
229
|
+
});
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const threshold = options.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
|
|
234
|
+
const size = properties.size;
|
|
235
|
+
const partSize =
|
|
236
|
+
size !== undefined && size >= threshold && canUseMultipart(source, sink)
|
|
237
|
+
? negotiatePartSize(source, sink, size)
|
|
238
|
+
: undefined;
|
|
239
|
+
const limiter = options.concurrencyLimiter ?? defaultConcurrencyLimiter;
|
|
240
|
+
const retryOptions = options.retry ?? DEFAULT_RETRY;
|
|
241
|
+
|
|
242
|
+
await withRetry(async () => {
|
|
243
|
+
if (partSize !== undefined) {
|
|
244
|
+
await multipartTransfer(
|
|
245
|
+
source,
|
|
246
|
+
sourcePath,
|
|
247
|
+
sink,
|
|
248
|
+
destPath,
|
|
249
|
+
operationId,
|
|
250
|
+
parentOperationId,
|
|
251
|
+
partSize,
|
|
252
|
+
limiter,
|
|
253
|
+
options,
|
|
254
|
+
size,
|
|
255
|
+
);
|
|
256
|
+
} else {
|
|
257
|
+
await streamingTransfer(
|
|
258
|
+
source,
|
|
259
|
+
sourcePath,
|
|
260
|
+
sink,
|
|
261
|
+
destPath,
|
|
262
|
+
operationId,
|
|
263
|
+
parentOperationId,
|
|
264
|
+
options,
|
|
265
|
+
size,
|
|
82
266
|
);
|
|
83
|
-
const reader = adaptedStream.getReader();
|
|
84
|
-
const chunks: ChunkRef[] = [];
|
|
85
|
-
for (;;) {
|
|
86
|
-
const { done, value } = await reader.read();
|
|
87
|
-
if (done) break;
|
|
88
|
-
chunks.push(value);
|
|
89
|
-
bytesProcessed += chunkLength(value);
|
|
90
|
-
options.telemetry?.onProgress?.({ operationId, type: "copy", bytesProcessed, totalBytes });
|
|
91
|
-
}
|
|
92
|
-
yield {
|
|
93
|
-
index: part.index,
|
|
94
|
-
offset: part.offset,
|
|
95
|
-
kind: sink.kind,
|
|
96
|
-
stream: new ReadableStream<ChunkRef>({
|
|
97
|
-
start(controller) {
|
|
98
|
-
for (const chunk of chunks) controller.enqueue(chunk);
|
|
99
|
-
controller.close();
|
|
100
|
-
},
|
|
101
|
-
}),
|
|
102
|
-
complete: () => part.complete(),
|
|
103
|
-
};
|
|
104
267
|
}
|
|
268
|
+
}, retryOptions);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function moveSingle(
|
|
272
|
+
source: IOProvider,
|
|
273
|
+
sourcePath: string,
|
|
274
|
+
sink: IOProvider,
|
|
275
|
+
destPath: string,
|
|
276
|
+
options: TransferOptions,
|
|
277
|
+
parentOperationId: string | undefined,
|
|
278
|
+
properties: ItemProperties,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
if (source.canDirectTransfer?.(sink) && source.directMove) {
|
|
281
|
+
const operationId = crypto.randomUUID();
|
|
282
|
+
await source.directMove(sourcePath, destPath, {
|
|
283
|
+
operationId,
|
|
284
|
+
hooks: withParentOperationId(options.telemetry, parentOperationId),
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
105
287
|
}
|
|
106
|
-
await
|
|
288
|
+
await copySingle(source, sourcePath, sink, destPath, options, parentOperationId, properties);
|
|
289
|
+
await withRetry(() => source.delete(sourcePath), options.retry ?? DEFAULT_RETRY);
|
|
107
290
|
}
|
|
108
291
|
|
|
109
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Resolves the effective destination root for a recursive transfer,
|
|
294
|
+
* following `cp -r`/`mv` semantics: a destination that doesn't exist becomes
|
|
295
|
+
* the copy itself; an existing folder gets the source nested inside it as
|
|
296
|
+
* `dest/<source-basename>`; an existing file is rejected.
|
|
297
|
+
*/
|
|
298
|
+
async function resolveRecursiveDestRoot(
|
|
299
|
+
sourcePath: string,
|
|
300
|
+
sink: IOProvider,
|
|
301
|
+
destPath: string,
|
|
302
|
+
): Promise<string> {
|
|
303
|
+
let destProperties: ItemProperties | undefined;
|
|
304
|
+
try {
|
|
305
|
+
destProperties = await sink.getProperties(destPath);
|
|
306
|
+
} catch {
|
|
307
|
+
destProperties = undefined;
|
|
308
|
+
}
|
|
309
|
+
if (destProperties === undefined) {
|
|
310
|
+
return destPath;
|
|
311
|
+
}
|
|
312
|
+
if (!destProperties.isFolder) {
|
|
313
|
+
throw new Error(`Cannot copy/move folder "${sourcePath}" onto existing file "${destPath}"`);
|
|
314
|
+
}
|
|
315
|
+
const baseName = sourcePath.replace(/\/+$/, "").split("/").pop() || sourcePath;
|
|
316
|
+
return joinPath(destPath, baseName);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function recursiveTransfer(
|
|
110
320
|
source: IOProvider,
|
|
321
|
+
sourcePath: string,
|
|
111
322
|
sink: IOProvider,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
323
|
+
destPath: string,
|
|
324
|
+
options: TransferOptions,
|
|
325
|
+
mode: "copy" | "move",
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
if (source.canDirectTransfer?.(sink) && source.supportsRecursiveDirectTransfer) {
|
|
328
|
+
const directFn = mode === "copy" ? source.directCopy : source.directMove;
|
|
329
|
+
if (directFn) {
|
|
330
|
+
const operationId = crypto.randomUUID();
|
|
331
|
+
await directFn(sourcePath, destPath, { operationId, hooks: options.telemetry ?? {} });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const resolvedDestRoot = await resolveRecursiveDestRoot(sourcePath, sink, destPath);
|
|
337
|
+
const recursiveOperationId = crypto.randomUUID();
|
|
338
|
+
const limiter = options.concurrencyLimiter ?? defaultConcurrencyLimiter;
|
|
339
|
+
|
|
340
|
+
let discovered = 0;
|
|
341
|
+
let completed = 0;
|
|
342
|
+
const reportProgress = (): void => {
|
|
343
|
+
options.telemetry?.onProgress?.({
|
|
344
|
+
operationId: recursiveOperationId,
|
|
345
|
+
type: mode,
|
|
346
|
+
bytesProcessed: 0,
|
|
347
|
+
itemsProcessed: completed,
|
|
348
|
+
totalItems: discovered,
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
async function processEntry(entry: { path: string; properties: ItemProperties }): Promise<void> {
|
|
353
|
+
discovered += 1;
|
|
354
|
+
reportProgress();
|
|
355
|
+
const entrySourcePath = joinPath(sourcePath, entry.path);
|
|
356
|
+
const entryDestPath = joinPath(resolvedDestRoot, entry.path);
|
|
357
|
+
if (entry.properties.isFolder) {
|
|
358
|
+
await sink.createFolder?.(entryDestPath);
|
|
359
|
+
} else if (mode === "copy") {
|
|
360
|
+
await copySingle(
|
|
361
|
+
source,
|
|
362
|
+
entrySourcePath,
|
|
363
|
+
sink,
|
|
364
|
+
entryDestPath,
|
|
365
|
+
options,
|
|
366
|
+
recursiveOperationId,
|
|
367
|
+
entry.properties,
|
|
368
|
+
);
|
|
369
|
+
} else if (source.canDirectTransfer?.(sink) && source.directMove) {
|
|
370
|
+
// A per-file directMove deletes its source atomically as part of the
|
|
371
|
+
// provider's own operation - safe to do immediately. The non-direct
|
|
372
|
+
// case below must NOT delete per entry: only after every entry
|
|
373
|
+
// succeeds does the caller delete the whole source root once.
|
|
374
|
+
const operationId = crypto.randomUUID();
|
|
375
|
+
await source.directMove(entrySourcePath, entryDestPath, {
|
|
376
|
+
operationId,
|
|
377
|
+
hooks: withParentOperationId(options.telemetry, recursiveOperationId),
|
|
378
|
+
});
|
|
379
|
+
} else {
|
|
380
|
+
await copySingle(
|
|
381
|
+
source,
|
|
382
|
+
entrySourcePath,
|
|
383
|
+
sink,
|
|
384
|
+
entryDestPath,
|
|
385
|
+
options,
|
|
386
|
+
recursiveOperationId,
|
|
387
|
+
entry.properties,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
completed += 1;
|
|
391
|
+
reportProgress();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for await (const _entry of mapAsyncIterableConcurrently(
|
|
395
|
+
source.list(sourcePath, { recursive: true }),
|
|
396
|
+
processEntry,
|
|
397
|
+
limiter,
|
|
398
|
+
)) {
|
|
399
|
+
// work happens inside processEntry - nothing to do with the (void) result.
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (mode === "move") {
|
|
403
|
+
await withRetry(() => source.delete(sourcePath), options.retry ?? DEFAULT_RETRY);
|
|
404
|
+
}
|
|
121
405
|
}
|
|
122
406
|
|
|
123
407
|
/**
|
|
124
|
-
* Copies `sourcePath` on `source` to `destPath` on `sink`.
|
|
408
|
+
* Copies `sourcePath` on `source` to `destPath` on `sink`. When `sourcePath`
|
|
409
|
+
* is a folder, recurses: uses a folder-aware `directCopy` when the source
|
|
410
|
+
* declares `supportsRecursiveDirectTransfer`, otherwise lists the folder and
|
|
411
|
+
* transfers each entry (bounded by `options.concurrencyLimiter`), following
|
|
412
|
+
* `cp -r` target semantics (see {@link resolveRecursiveDestRoot}).
|
|
125
413
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* multipart transfer (when both sides support it
|
|
129
|
-
* `options.multipartThreshold
|
|
414
|
+
* For a single file: uses `source.directCopy` when
|
|
415
|
+
* `source.canDirectTransfer?.(sink)` reports eligibility. Otherwise falls
|
|
416
|
+
* back to multipart transfer (when both sides support it, size crosses
|
|
417
|
+
* `options.multipartThreshold`, and part-size negotiation succeeds) or plain
|
|
418
|
+
* streaming - retried per `options.retry` on `TransientIOError`.
|
|
130
419
|
*/
|
|
131
420
|
export async function copy(
|
|
132
421
|
source: IOProvider,
|
|
@@ -135,40 +424,19 @@ export async function copy(
|
|
|
135
424
|
destPath: string,
|
|
136
425
|
options: TransferOptions = {},
|
|
137
426
|
): Promise<void> {
|
|
138
|
-
const operationId = crypto.randomUUID();
|
|
139
|
-
if (source.canDirectTransfer?.(sink) && source.directCopy) {
|
|
140
|
-
await source.directCopy(sourcePath, destPath);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
427
|
const properties = await source.getProperties(sourcePath);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
await multipartTransfer(
|
|
147
|
-
source,
|
|
148
|
-
sourcePath,
|
|
149
|
-
sink,
|
|
150
|
-
destPath,
|
|
151
|
-
operationId,
|
|
152
|
-
options,
|
|
153
|
-
properties.size,
|
|
154
|
-
);
|
|
428
|
+
if (properties.isFolder) {
|
|
429
|
+
await recursiveTransfer(source, sourcePath, sink, destPath, options, "copy");
|
|
155
430
|
return;
|
|
156
431
|
}
|
|
157
|
-
await
|
|
158
|
-
source,
|
|
159
|
-
sourcePath,
|
|
160
|
-
sink,
|
|
161
|
-
destPath,
|
|
162
|
-
operationId,
|
|
163
|
-
options,
|
|
164
|
-
properties.size,
|
|
165
|
-
);
|
|
432
|
+
await copySingle(source, sourcePath, sink, destPath, options, undefined, properties);
|
|
166
433
|
}
|
|
167
434
|
|
|
168
435
|
/**
|
|
169
|
-
* Moves `sourcePath` on `source` to `destPath` on `sink`.
|
|
170
|
-
*
|
|
171
|
-
* followed by deleting the
|
|
436
|
+
* Moves `sourcePath` on `source` to `destPath` on `sink`. Folder handling
|
|
437
|
+
* mirrors {@link copy}. For a single file, uses `source.directMove` when
|
|
438
|
+
* eligible, otherwise performs a {@link copy} followed by deleting the
|
|
439
|
+
* source item (each retried independently per `options.retry`).
|
|
172
440
|
*/
|
|
173
441
|
export async function move(
|
|
174
442
|
source: IOProvider,
|
|
@@ -177,10 +445,10 @@ export async function move(
|
|
|
177
445
|
destPath: string,
|
|
178
446
|
options: TransferOptions = {},
|
|
179
447
|
): Promise<void> {
|
|
180
|
-
|
|
181
|
-
|
|
448
|
+
const properties = await source.getProperties(sourcePath);
|
|
449
|
+
if (properties.isFolder) {
|
|
450
|
+
await recursiveTransfer(source, sourcePath, sink, destPath, options, "move");
|
|
182
451
|
return;
|
|
183
452
|
}
|
|
184
|
-
await
|
|
185
|
-
await source.delete(sourcePath);
|
|
453
|
+
await moveSingle(source, sourcePath, sink, destPath, options, undefined, properties);
|
|
186
454
|
}
|
package/src/withRetry.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { TransientIOError } from "@flowscripter/pluggable-io-framework-api";
|
|
2
|
+
|
|
3
|
+
export interface RetryOptions {
|
|
4
|
+
readonly maxRetries: number;
|
|
5
|
+
/** Delay in ms before the given retry attempt (1-based). Defaults to `min(200 * 2^attempt, 5000)`. */
|
|
6
|
+
readonly backoffMs?: (attempt: number) => number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function defaultBackoffMs(attempt: number): number {
|
|
10
|
+
return Math.min(200 * 2 ** attempt, 5000);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs `fn`, retrying only on `TransientIOError` up to `options.maxRetries`
|
|
15
|
+
* times with a delay between attempts. Any other error - including a plain
|
|
16
|
+
* `Error` a provider hasn't wrapped - is treated as non-retryable and
|
|
17
|
+
* rethrown immediately.
|
|
18
|
+
*/
|
|
19
|
+
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T> {
|
|
20
|
+
const backoffMs = options.backoffMs ?? defaultBackoffMs;
|
|
21
|
+
let attempt = 0;
|
|
22
|
+
for (;;) {
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (!(error instanceof TransientIOError) || attempt >= options.maxRetries) {
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
attempt += 1;
|
|
30
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs(attempt)));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|