@flowscripter/pluggable-io-framework 1.2.7 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +47 -178
  2. package/dist/index.d.ts +11 -4
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -4
  5. package/dist/src/concurrency/AsyncChannel.d.ts +9 -0
  6. package/dist/src/concurrency/AsyncChannel.d.ts.map +1 -0
  7. package/dist/src/concurrency/AsyncChannel.js +48 -0
  8. package/dist/src/{ConcurrencyLimiter.d.ts → concurrency/ConcurrencyLimiter.d.ts} +1 -12
  9. package/dist/src/concurrency/ConcurrencyLimiter.d.ts.map +1 -0
  10. package/dist/src/concurrency/ConcurrencyLimiter.js +64 -0
  11. package/dist/src/concurrency/mapAsyncIterableConcurrently.d.ts +13 -0
  12. package/dist/src/concurrency/mapAsyncIterableConcurrently.d.ts.map +1 -0
  13. package/dist/src/concurrency/mapAsyncIterableConcurrently.js +61 -0
  14. package/dist/src/decorators/locallyCached.d.ts +9 -10
  15. package/dist/src/decorators/locallyCached.d.ts.map +1 -1
  16. package/dist/src/decorators/locallyCached.js +23 -16
  17. package/dist/src/decorators/seekable.d.ts +4 -4
  18. package/dist/src/decorators/seekable.d.ts.map +1 -1
  19. package/dist/src/decorators/seekable.js +2 -2
  20. package/dist/src/registry/ProviderRegistry.d.ts +52 -0
  21. package/dist/src/registry/ProviderRegistry.d.ts.map +1 -0
  22. package/dist/src/registry/ProviderRegistry.js +148 -0
  23. package/dist/src/registry/detectProtocol.d.ts +8 -0
  24. package/dist/src/registry/detectProtocol.d.ts.map +1 -0
  25. package/dist/src/registry/detectProtocol.js +15 -0
  26. package/dist/src/registry/negotiateTransfer.d.ts +29 -0
  27. package/dist/src/registry/negotiateTransfer.d.ts.map +1 -0
  28. package/dist/src/registry/negotiateTransfer.js +69 -0
  29. package/dist/src/retry/RetryOptions.d.ts +19 -0
  30. package/dist/src/retry/RetryOptions.d.ts.map +1 -0
  31. package/dist/src/retry/RetryOptions.js +9 -0
  32. package/dist/src/{withRetry.d.ts → retry/withRetry.d.ts} +1 -5
  33. package/dist/src/retry/withRetry.d.ts.map +1 -0
  34. package/dist/src/{withRetry.js → retry/withRetry.js} +2 -5
  35. package/dist/src/transfer/EntryContext.d.ts +16 -0
  36. package/dist/src/transfer/EntryContext.d.ts.map +1 -0
  37. package/dist/src/transfer/EntryContext.js +1 -0
  38. package/dist/src/transfer/TransferOptions.d.ts +45 -0
  39. package/dist/src/transfer/TransferOptions.d.ts.map +1 -0
  40. package/dist/src/transfer/TransferOptions.js +1 -0
  41. package/dist/src/transfer/TransferResult.d.ts +9 -0
  42. package/dist/src/transfer/TransferResult.d.ts.map +1 -0
  43. package/dist/src/transfer/TransferResult.js +1 -0
  44. package/dist/src/transfer/aggregateResults.d.ts +4 -0
  45. package/dist/src/transfer/aggregateResults.d.ts.map +1 -0
  46. package/dist/src/transfer/aggregateResults.js +9 -0
  47. package/dist/src/transfer/copyMove.d.ts +23 -0
  48. package/dist/src/transfer/copyMove.d.ts.map +1 -0
  49. package/dist/src/transfer/copyMove.js +68 -0
  50. package/dist/src/transfer/leaseTransfer.d.ts +14 -0
  51. package/dist/src/transfer/leaseTransfer.d.ts.map +1 -0
  52. package/dist/src/transfer/leaseTransfer.js +74 -0
  53. package/dist/src/transfer/multipartTransfer.d.ts +23 -0
  54. package/dist/src/transfer/multipartTransfer.d.ts.map +1 -0
  55. package/dist/src/transfer/multipartTransfer.js +94 -0
  56. package/dist/src/transfer/negotiatePartSize.d.ts +10 -0
  57. package/dist/src/transfer/negotiatePartSize.d.ts.map +1 -0
  58. package/dist/src/transfer/negotiatePartSize.js +27 -0
  59. package/dist/src/transfer/patternTransfer.d.ts +12 -0
  60. package/dist/src/transfer/patternTransfer.d.ts.map +1 -0
  61. package/dist/src/transfer/patternTransfer.js +61 -0
  62. package/dist/src/transfer/rangeReadableMultipartReader.d.ts +8 -0
  63. package/dist/src/transfer/rangeReadableMultipartReader.d.ts.map +1 -0
  64. package/dist/src/transfer/rangeReadableMultipartReader.js +19 -0
  65. package/dist/src/transfer/recursiveTransfer.d.ts +13 -0
  66. package/dist/src/transfer/recursiveTransfer.d.ts.map +1 -0
  67. package/dist/src/transfer/recursiveTransfer.js +112 -0
  68. package/dist/src/transfer/streamTransfer.d.ts +32 -0
  69. package/dist/src/transfer/streamTransfer.d.ts.map +1 -0
  70. package/dist/src/transfer/streamTransfer.js +186 -0
  71. package/dist/src/transfer/transferEntry.d.ts +25 -0
  72. package/dist/src/transfer/transferEntry.d.ts.map +1 -0
  73. package/dist/src/transfer/transferEntry.js +136 -0
  74. package/dist/src/util/abort.d.ts +12 -0
  75. package/dist/src/util/abort.d.ts.map +1 -0
  76. package/dist/src/util/abort.js +39 -0
  77. package/dist/src/util/applyPayloadConverter.d.ts +9 -0
  78. package/dist/src/util/applyPayloadConverter.d.ts.map +1 -0
  79. package/dist/src/util/applyPayloadConverter.js +26 -0
  80. package/dist/src/util/describePath.d.ts +6 -0
  81. package/dist/src/util/describePath.d.ts.map +1 -0
  82. package/dist/src/util/describePath.js +9 -0
  83. package/dist/src/util/globToRegex.d.ts +8 -0
  84. package/dist/src/util/globToRegex.d.ts.map +1 -0
  85. package/dist/src/util/globToRegex.js +35 -0
  86. package/dist/src/util/itemLength.d.ts +4 -0
  87. package/dist/src/util/itemLength.d.ts.map +1 -0
  88. package/dist/src/util/itemLength.js +5 -0
  89. package/dist/src/util/keys.d.ts +8 -0
  90. package/dist/src/util/keys.d.ts.map +1 -0
  91. package/dist/src/util/keys.js +19 -0
  92. package/dist/src/util/withParentOperationId.d.ts +4 -0
  93. package/dist/src/util/withParentOperationId.d.ts.map +1 -0
  94. package/dist/src/util/withParentOperationId.js +12 -0
  95. package/index.ts +11 -4
  96. package/package.json +4 -3
  97. package/src/concurrency/AsyncChannel.ts +52 -0
  98. package/src/concurrency/ConcurrencyLimiter.ts +72 -0
  99. package/src/concurrency/mapAsyncIterableConcurrently.ts +66 -0
  100. package/src/decorators/locallyCached.ts +30 -22
  101. package/src/decorators/seekable.ts +7 -7
  102. package/src/registry/ProviderRegistry.ts +241 -0
  103. package/src/registry/detectProtocol.ts +16 -0
  104. package/src/registry/negotiateTransfer.ts +121 -0
  105. package/src/retry/RetryOptions.ts +26 -0
  106. package/src/{withRetry.ts → retry/withRetry.ts} +2 -12
  107. package/src/transfer/EntryContext.ts +17 -0
  108. package/src/transfer/TransferOptions.ts +45 -0
  109. package/src/transfer/TransferResult.ts +8 -0
  110. package/src/transfer/aggregateResults.ts +15 -0
  111. package/src/transfer/copyMove.ts +113 -0
  112. package/src/transfer/leaseTransfer.ts +93 -0
  113. package/src/transfer/multipartTransfer.ts +119 -0
  114. package/src/transfer/negotiatePartSize.ts +40 -0
  115. package/src/transfer/patternTransfer.ts +84 -0
  116. package/src/transfer/rangeReadableMultipartReader.ts +30 -0
  117. package/src/transfer/recursiveTransfer.ts +147 -0
  118. package/src/transfer/streamTransfer.ts +214 -0
  119. package/src/transfer/transferEntry.ts +185 -0
  120. package/src/util/abort.ts +42 -0
  121. package/src/util/applyPayloadConverter.ts +37 -0
  122. package/src/util/describePath.ts +12 -0
  123. package/src/util/globToRegex.ts +32 -0
  124. package/src/util/itemLength.ts +6 -0
  125. package/src/util/keys.ts +21 -0
  126. package/src/util/withParentOperationId.ts +17 -0
  127. package/dist/src/ConcurrencyLimiter.d.ts.map +0 -1
  128. package/dist/src/ConcurrencyLimiter.js +0 -161
  129. package/dist/src/ProviderRegistry.d.ts +0 -14
  130. package/dist/src/ProviderRegistry.d.ts.map +0 -1
  131. package/dist/src/ProviderRegistry.js +0 -22
  132. package/dist/src/chunkLength.d.ts +0 -3
  133. package/dist/src/chunkLength.d.ts.map +0 -1
  134. package/dist/src/chunkLength.js +0 -4
  135. package/dist/src/copyMove.d.ts +0 -46
  136. package/dist/src/copyMove.d.ts.map +0 -1
  137. package/dist/src/copyMove.js +0 -280
  138. package/dist/src/withRetry.d.ts.map +0 -1
  139. package/src/ConcurrencyLimiter.ts +0 -178
  140. package/src/ProviderRegistry.ts +0 -32
  141. package/src/chunkLength.ts +0 -5
  142. package/src/copyMove.ts +0 -458
@@ -0,0 +1,84 @@
1
+ import {
2
+ type EntryProperties,
3
+ type IOProvider,
4
+ PermanentIOError,
5
+ } from "@flowscripter/pluggable-io-framework-api";
6
+ import { defaultConcurrencyLimiter } from "../concurrency/ConcurrencyLimiter.ts";
7
+ import { mapAsyncIterableConcurrently } from "../concurrency/mapAsyncIterableConcurrently.ts";
8
+ import { throwIfAborted } from "../util/abort.ts";
9
+ import { globToRegex } from "../util/globToRegex.ts";
10
+ import { joinKey } from "../util/keys.ts";
11
+ import { aggregateResults } from "./aggregateResults.ts";
12
+ import { basePath, transferEntry } from "./transferEntry.ts";
13
+ import type { TransferOptions } from "./TransferOptions.ts";
14
+ import type { TransferResult } from "./TransferResult.ts";
15
+
16
+ /**
17
+ * Transfers every entry directly inside `containerKey` whose name matches
18
+ * the glob `pattern` into the destination container, one single-entry
19
+ * transfer per match, bounded by the concurrency limiter. Matching
20
+ * containers are skipped. A move deletes each source entry after it is
21
+ * transferred.
22
+ */
23
+ export async function patternTransfer(
24
+ source: IOProvider,
25
+ containerKey: string,
26
+ pattern: string,
27
+ sink: IOProvider,
28
+ destContainerKey: string,
29
+ options: TransferOptions,
30
+ type: "copy" | "move",
31
+ ): Promise<TransferResult> {
32
+ if (typeof source.list !== "function") {
33
+ throw new PermanentIOError("The source provider does not support listing containers");
34
+ }
35
+ const operationId = crypto.randomUUID();
36
+ const limiter = options.concurrencyLimiter ?? defaultConcurrencyLimiter;
37
+ let discovered = 0;
38
+ let completed = 0;
39
+ const reportProgress = (): void => {
40
+ options.telemetry?.onProgress?.({
41
+ operationId,
42
+ type,
43
+ bytesProcessed: 0,
44
+ entriesProcessed: completed,
45
+ totalEntries: discovered,
46
+ });
47
+ };
48
+
49
+ async function processEntry(entry: {
50
+ path: string;
51
+ properties: EntryProperties;
52
+ }): Promise<TransferResult | undefined> {
53
+ throwIfAborted(options.signal);
54
+ if (options.stop?.aborted || entry.properties.isContainer) {
55
+ return undefined;
56
+ }
57
+ discovered += 1;
58
+ reportProgress();
59
+ const result = await transferEntry({
60
+ source,
61
+ sourceKey: joinKey(source, containerKey, entry.path),
62
+ sink,
63
+ destKey: joinKey(sink, destContainerKey, entry.path),
64
+ properties: entry.properties,
65
+ options,
66
+ parentOperationId: operationId,
67
+ type,
68
+ deleteSource: type === "move",
69
+ });
70
+ completed += 1;
71
+ reportProgress();
72
+ return result;
73
+ }
74
+
75
+ const results: TransferResult[] = [];
76
+ for await (const result of mapAsyncIterableConcurrently(
77
+ source.list(containerKey, { recursive: false, regex: globToRegex(pattern) }),
78
+ processEntry,
79
+ limiter,
80
+ )) {
81
+ if (result) results.push(result);
82
+ }
83
+ return aggregateResults(results, basePath(source, sink, options), options.stop?.aborted ?? false);
84
+ }
@@ -0,0 +1,30 @@
1
+ import type {
2
+ Part,
3
+ PayloadKind,
4
+ RangeReadable,
5
+ StreamHandle,
6
+ } from "@flowscripter/pluggable-io-framework-api";
7
+
8
+ /**
9
+ * Splits a `RangeReadable` handle into parts of `partSize` bytes covering
10
+ * `totalSize`. Each part's stream is opened with `readRange(start, end)`
11
+ * (`end` exclusive) when the part is pulled.
12
+ */
13
+ export async function* rangeReadableMultipartReader<K extends PayloadKind>(
14
+ handle: StreamHandle<K> & RangeReadable<K>,
15
+ totalSize: number,
16
+ partSize: number,
17
+ ): AsyncGenerator<Part<K>> {
18
+ const partCount = Math.max(1, Math.ceil(totalSize / partSize));
19
+ for (let index = 0; index < partCount; index += 1) {
20
+ const start = index * partSize;
21
+ const end = Math.min(start + partSize, totalSize);
22
+ yield {
23
+ index,
24
+ offset: start,
25
+ kind: handle.kind,
26
+ stream: await handle.readRange(start, end),
27
+ complete: async () => {},
28
+ };
29
+ }
30
+ }
@@ -0,0 +1,147 @@
1
+ import {
2
+ type EntryProperties,
3
+ type IOProvider,
4
+ PermanentIOError,
5
+ } from "@flowscripter/pluggable-io-framework-api";
6
+ import { defaultConcurrencyLimiter } from "../concurrency/ConcurrencyLimiter.ts";
7
+ import { mapAsyncIterableConcurrently } from "../concurrency/mapAsyncIterableConcurrently.ts";
8
+ import { DEFAULT_RETRY } from "../retry/RetryOptions.ts";
9
+ import { withRetry } from "../retry/withRetry.ts";
10
+ import { throwIfAborted } from "../util/abort.ts";
11
+ import { baseName, joinKey } from "../util/keys.ts";
12
+ import { aggregateResults } from "./aggregateResults.ts";
13
+ import { basePath, transferEntry } from "./transferEntry.ts";
14
+ import type { TransferOptions } from "./TransferOptions.ts";
15
+ import type { TransferResult } from "./TransferResult.ts";
16
+
17
+ /**
18
+ * Resolves the effective destination root for a recursive transfer,
19
+ * following `cp -r`/`mv` semantics: a destination that doesn't exist becomes
20
+ * the copy itself; an existing container gets the source nested inside it
21
+ * as `dest/<source-basename>`; an existing entry is rejected.
22
+ */
23
+ async function resolveRecursiveDestRoot(
24
+ sourceKey: string,
25
+ sink: IOProvider,
26
+ destKey: string,
27
+ ): Promise<string> {
28
+ let destProperties: EntryProperties | undefined;
29
+ try {
30
+ destProperties = await sink.getProperties(destKey);
31
+ } catch {
32
+ destProperties = undefined;
33
+ }
34
+ if (destProperties === undefined) {
35
+ return destKey;
36
+ }
37
+ if (!destProperties.isContainer) {
38
+ throw new PermanentIOError(
39
+ `Cannot copy/move container "${sourceKey}" onto existing entry "${destKey}"`,
40
+ );
41
+ }
42
+ return joinKey(sink, destKey, baseName(sourceKey));
43
+ }
44
+
45
+ /**
46
+ * Transfers a whole container. Uses a container-aware
47
+ * `directCopy`/`directMove` when the source declares
48
+ * `supportsRecursiveDirectTransfer`; otherwise lists the container and
49
+ * transfers each entry, bounded by the concurrency limiter. A non-direct
50
+ * move deletes the source container once, after every entry succeeded and
51
+ * only if the transfer was not stopped.
52
+ */
53
+ export async function recursiveTransfer(
54
+ source: IOProvider,
55
+ sourceKey: string,
56
+ sink: IOProvider,
57
+ destKey: string,
58
+ options: TransferOptions,
59
+ type: "copy" | "move",
60
+ ): Promise<TransferResult> {
61
+ const path = basePath(source, sink, options);
62
+ const resolvedDestRoot = await resolveRecursiveDestRoot(sourceKey, sink, destKey);
63
+
64
+ if (
65
+ options.directTransfer !== false &&
66
+ source.canDirectTransfer?.(sink) &&
67
+ source.supportsRecursiveDirectTransfer
68
+ ) {
69
+ const direct = type === "move" ? source.directMove : source.directCopy;
70
+ if (direct) {
71
+ throwIfAborted(options.signal);
72
+ const telemetry = { operationId: crypto.randomUUID(), hooks: options.telemetry ?? {} };
73
+ await direct.call(source, sourceKey, resolvedDestRoot, telemetry);
74
+ return { stopped: false, bytes: 0, items: 0, path: `${path}, direct` };
75
+ }
76
+ }
77
+ if (typeof source.list !== "function") {
78
+ throw new PermanentIOError("The source provider does not support listing containers");
79
+ }
80
+ if (type === "move" && typeof source.delete !== "function") {
81
+ throw new PermanentIOError("The source provider does not support delete, so it cannot move");
82
+ }
83
+
84
+ const recursiveOperationId = crypto.randomUUID();
85
+ const limiter = options.concurrencyLimiter ?? defaultConcurrencyLimiter;
86
+ let discovered = 0;
87
+ let completed = 0;
88
+ const reportProgress = (): void => {
89
+ options.telemetry?.onProgress?.({
90
+ operationId: recursiveOperationId,
91
+ type,
92
+ bytesProcessed: 0,
93
+ entriesProcessed: completed,
94
+ totalEntries: discovered,
95
+ });
96
+ };
97
+
98
+ async function processEntry(entry: {
99
+ path: string;
100
+ properties: EntryProperties;
101
+ }): Promise<TransferResult | undefined> {
102
+ throwIfAborted(options.signal);
103
+ if (options.stop?.aborted) {
104
+ return undefined;
105
+ }
106
+ discovered += 1;
107
+ reportProgress();
108
+ const entryDestKey = joinKey(sink, resolvedDestRoot, entry.path);
109
+ let result: TransferResult | undefined;
110
+ if (entry.properties.isContainer) {
111
+ await sink.createContainer?.(entryDestKey);
112
+ } else {
113
+ result = await transferEntry({
114
+ source,
115
+ sourceKey: joinKey(source, sourceKey, entry.path),
116
+ sink,
117
+ destKey: entryDestKey,
118
+ properties: entry.properties,
119
+ options,
120
+ parentOperationId: recursiveOperationId,
121
+ type,
122
+ deleteSource: false,
123
+ });
124
+ }
125
+ completed += 1;
126
+ reportProgress();
127
+ return result;
128
+ }
129
+
130
+ const results: TransferResult[] = [];
131
+ for await (const result of mapAsyncIterableConcurrently(
132
+ source.list(sourceKey, { recursive: true }),
133
+ processEntry,
134
+ limiter,
135
+ )) {
136
+ if (result) results.push(result);
137
+ }
138
+
139
+ const outcome = aggregateResults(results, path, options.stop?.aborted ?? false);
140
+ if (type === "move" && !outcome.stopped) {
141
+ await withRetry(
142
+ () => (source.delete as (key: string) => Promise<void>).call(source, sourceKey),
143
+ options.retry ?? DEFAULT_RETRY,
144
+ );
145
+ }
146
+ return outcome;
147
+ }
@@ -0,0 +1,214 @@
1
+ import {
2
+ type IOProvider,
3
+ type Item,
4
+ isRangeReadable,
5
+ isResumableWritable,
6
+ type StreamHandle,
7
+ TransientIOError,
8
+ } from "@flowscripter/pluggable-io-framework-api";
9
+ import { backoff, DEFAULT_RETRY } from "../retry/RetryOptions.ts";
10
+ import { createAbortError, watchSignals } from "../util/abort.ts";
11
+ import { applyPayloadConverter } from "../util/applyPayloadConverter.ts";
12
+ import { itemLength } from "../util/itemLength.ts";
13
+ import type { EntryContext, EntryOutcome } from "./EntryContext.ts";
14
+
15
+ /** Which side of the transfer a failure came from. */
16
+ class SideError extends Error {
17
+ public constructor(
18
+ public readonly side: "source" | "sink",
19
+ cause: unknown,
20
+ ) {
21
+ super("transfer side failed", { cause });
22
+ }
23
+ }
24
+
25
+ export interface StreamTransferInput {
26
+ readonly source: IOProvider;
27
+ readonly sourceKey: string;
28
+ readonly readable: StreamHandle;
29
+ readonly sink: IOProvider;
30
+ readonly destKey: string;
31
+ readonly writable: StreamHandle;
32
+ /** Total bytes for a bounded source, when known. */
33
+ readonly totalBytes?: number;
34
+ readonly context: EntryContext;
35
+ }
36
+
37
+ function ignore(): void {}
38
+
39
+ /**
40
+ * Pipes a readable handle into a writable handle item by item, honouring
41
+ * `signal` (abort) and `stop` (graceful close).
42
+ *
43
+ * On a `TransientIOError`:
44
+ * - bounded source with `RangeReadable`, and a writable exposing a resume
45
+ * token: the sink is reopened with the token and the source re-read from
46
+ * the sink's `startOffset`;
47
+ * - other bounded transfers restart from scratch;
48
+ * - unbounded source failure: the source is reopened, the sink kept open,
49
+ * the first new item flagged with `discontinuity`, and `onGap` reported;
50
+ * - unbounded sink failure: the sink is reopened with its resume token, or
51
+ * the transfer fails.
52
+ *
53
+ * `retry.maxRetries` counts consecutive failures and resets once an item is
54
+ * written.
55
+ */
56
+ export async function streamTransfer(input: StreamTransferInput): Promise<EntryOutcome> {
57
+ const { source, sourceKey, sink, destKey, totalBytes, context } = input;
58
+ const { options, hooks, operationId, type } = context;
59
+ const retry = options.retry ?? DEFAULT_RETRY;
60
+ const bounded = input.readable.bounded !== false;
61
+ const watch = watchSignals(options.signal, options.stop);
62
+
63
+ let readable = input.readable;
64
+ let writable = input.writable;
65
+ const convert = (stream: ReadableStream<Item>) =>
66
+ applyPayloadConverter(stream, readable.kind, sink.kind, options.converter);
67
+ let reader = convert(readable.stream as ReadableStream<Item>).getReader();
68
+ let writer = (writable.stream as WritableStream<Item>).getWriter();
69
+ let bytes = 0;
70
+ let items = 0;
71
+ let failures = 0;
72
+ let markGap = false;
73
+ let recover: { side: "source" | "sink"; run: () => Promise<void> } | undefined;
74
+
75
+ async function pump(): Promise<"done" | "stopped"> {
76
+ for (;;) {
77
+ let read:
78
+ | Awaited<ReturnType<ReadableStreamDefaultReader<Item>["read"]>>
79
+ | "aborted"
80
+ | "stopped";
81
+ try {
82
+ read = await Promise.race([reader.read(), watch.fired]);
83
+ } catch (error) {
84
+ throw new SideError("source", error);
85
+ }
86
+ if (read === "aborted") throw createAbortError();
87
+ if (read === "stopped") return "stopped";
88
+ if (read.done) return "done";
89
+
90
+ let item = read.value;
91
+ if (markGap) {
92
+ item = { ...item, attributes: { ...item.attributes, discontinuity: true } };
93
+ markGap = false;
94
+ }
95
+ let written: void | "aborted" | "stopped";
96
+ try {
97
+ written = await Promise.race([writer.write(item), watch.fired]);
98
+ } catch (error) {
99
+ throw new SideError("sink", error);
100
+ }
101
+ if (written === "aborted") throw createAbortError();
102
+ bytes += itemLength(item);
103
+ items += 1;
104
+ failures = 0;
105
+ hooks.onProgress?.({
106
+ operationId,
107
+ type,
108
+ bytesProcessed: bytes,
109
+ totalBytes: bounded ? totalBytes : undefined,
110
+ });
111
+ if (written === "stopped") return "stopped";
112
+ }
113
+ }
114
+
115
+ try {
116
+ for (;;) {
117
+ try {
118
+ if (recover) {
119
+ const action = recover;
120
+ recover = undefined;
121
+ try {
122
+ await action.run();
123
+ } catch (error) {
124
+ throw new SideError(action.side, error);
125
+ }
126
+ }
127
+ const outcome = await pump();
128
+ if (outcome === "stopped") {
129
+ await reader.cancel().catch(ignore);
130
+ }
131
+ await writer.close();
132
+ return { bytes, items, stopped: outcome === "stopped" };
133
+ } catch (error) {
134
+ if (error instanceof DOMException && error.name === "AbortError") {
135
+ await reader.cancel(error).catch(ignore);
136
+ await writer.abort(error).catch(ignore);
137
+ throw error;
138
+ }
139
+ const side = error instanceof SideError ? error.side : "sink";
140
+ const cause = error instanceof SideError ? error.cause : error;
141
+ failures += 1;
142
+ const retryable = cause instanceof TransientIOError && failures <= retry.maxRetries;
143
+
144
+ if (retryable && !bounded && side === "source" && retry.onGap !== "fail") {
145
+ await reader.cancel().catch(ignore);
146
+ const attempt = failures;
147
+ recover = {
148
+ side: "source",
149
+ run: async () => {
150
+ await backoff(retry, attempt);
151
+ readable = await source.getReadableStream(sourceKey);
152
+ reader = convert(readable.stream as ReadableStream<Item>).getReader();
153
+ markGap = true;
154
+ hooks.onGap?.(operationId, { atBytes: bytes, attempt });
155
+ },
156
+ };
157
+ continue;
158
+ }
159
+
160
+ const token =
161
+ retryable && isResumableWritable(writable) ? writable.resumeToken() : undefined;
162
+ const canResume = token !== undefined && (!bounded || isRangeReadable(readable));
163
+ if (retryable && canResume) {
164
+ // The failed sink is released, not aborted, so a provider that
165
+ // discards partial output on abort keeps it for the resume.
166
+ writer.releaseLock();
167
+ if (bounded) {
168
+ await reader.cancel().catch(ignore);
169
+ }
170
+ const attempt = failures;
171
+ recover = {
172
+ side: "sink",
173
+ run: async () => {
174
+ await backoff(retry, attempt);
175
+ const resumed = await sink.getWritableStream(destKey, { resume: token });
176
+ writable = resumed;
177
+ writer = (resumed.stream as WritableStream<Item>).getWriter();
178
+ if (bounded && isRangeReadable(readable)) {
179
+ const startOffset = resumed.startOffset ?? token.offset;
180
+ const end = totalBytes ?? Number.MAX_SAFE_INTEGER;
181
+ reader = convert(
182
+ (await readable.readRange(startOffset, end)) as ReadableStream<Item>,
183
+ ).getReader();
184
+ bytes = startOffset;
185
+ }
186
+ },
187
+ };
188
+ continue;
189
+ }
190
+
191
+ await reader.cancel(cause).catch(ignore);
192
+ await writer.abort(cause).catch(ignore);
193
+ if (!retryable || !bounded) {
194
+ throw cause;
195
+ }
196
+ const attempt = failures;
197
+ recover = {
198
+ side: "source",
199
+ run: async () => {
200
+ await backoff(retry, attempt);
201
+ readable = await source.getReadableStream(sourceKey);
202
+ reader = convert(readable.stream as ReadableStream<Item>).getReader();
203
+ writable = await sink.getWritableStream(destKey);
204
+ writer = (writable.stream as WritableStream<Item>).getWriter();
205
+ bytes = 0;
206
+ items = 0;
207
+ },
208
+ };
209
+ }
210
+ }
211
+ } finally {
212
+ watch.dispose();
213
+ }
214
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ BYTES_PAYLOAD_TYPE,
3
+ type EntryProperties,
4
+ type IOProvider,
5
+ isBufferProvider,
6
+ isFillReadable,
7
+ isRangeReadable,
8
+ type Item,
9
+ PermanentIOError,
10
+ type StreamHandle,
11
+ } from "@flowscripter/pluggable-io-framework-api";
12
+ import { defaultConcurrencyLimiter } from "../concurrency/ConcurrencyLimiter.ts";
13
+ import { DEFAULT_RETRY } from "../retry/RetryOptions.ts";
14
+ import { withRetry } from "../retry/withRetry.ts";
15
+ import { throwIfAborted } from "../util/abort.ts";
16
+ import { describeConverter } from "../util/describePath.ts";
17
+ import { withParentOperationId } from "../util/withParentOperationId.ts";
18
+ import type { EntryContext, EntryOutcome } from "./EntryContext.ts";
19
+ import { leaseDepth, leaseTransfer } from "./leaseTransfer.ts";
20
+ import { multipartTransfer } from "./multipartTransfer.ts";
21
+ import { negotiatePartSize } from "./negotiatePartSize.ts";
22
+ import { streamTransfer } from "./streamTransfer.ts";
23
+ import type { TransferOptions } from "./TransferOptions.ts";
24
+ import type { TransferResult } from "./TransferResult.ts";
25
+
26
+ const DEFAULT_MULTIPART_THRESHOLD = 64 * 1024 * 1024;
27
+
28
+ export interface TransferEntryInput {
29
+ readonly source: IOProvider;
30
+ readonly sourceKey: string;
31
+ readonly sink: IOProvider;
32
+ readonly destKey: string;
33
+ readonly properties: EntryProperties;
34
+ readonly options: TransferOptions;
35
+ readonly parentOperationId?: string;
36
+ /** `"move"` rejects unbounded sources and prefers `directMove`. */
37
+ readonly type: "copy" | "move";
38
+ /** Whether a non-direct move deletes the source entry afterwards. */
39
+ readonly deleteSource: boolean;
40
+ }
41
+
42
+ /** The path description before the transfer strategy is known. */
43
+ export function basePath(source: IOProvider, sink: IOProvider, options: TransferOptions): string {
44
+ if (options.path !== undefined) {
45
+ return options.path;
46
+ }
47
+ const via = options.converter ? describeConverter(options.converter) : "";
48
+ return `${source.kind} -> ${sink.kind}${via}`;
49
+ }
50
+
51
+ function checkPayloadType(readable: StreamHandle, options: TransferOptions): void {
52
+ const payloadType = readable.payloadType ?? BYTES_PAYLOAD_TYPE;
53
+ const accepted = options.writePayloadTypes ?? [BYTES_PAYLOAD_TYPE];
54
+ if (!accepted.includes(payloadType)) {
55
+ throw new PermanentIOError(
56
+ `Source payload type "${payloadType}" is not accepted by the sink (sink accepts: ${accepted.join(", ")})`,
57
+ );
58
+ }
59
+ }
60
+
61
+ async function cancelStream(handle: StreamHandle): Promise<void> {
62
+ await (handle.stream as ReadableStream<Item>).cancel().catch(() => {});
63
+ }
64
+
65
+ /**
66
+ * Transfers one entry: a direct provider transfer when eligible, otherwise
67
+ * multipart, the lease path or plain streaming, after checking the payload
68
+ * type and (for a move) that the source is bounded.
69
+ */
70
+ export async function transferEntry(input: TransferEntryInput): Promise<TransferResult> {
71
+ const { source, sourceKey, sink, destKey, properties, options, type } = input;
72
+ throwIfAborted(options.signal);
73
+ const path = basePath(source, sink, options);
74
+ if (options.stop?.aborted) {
75
+ return { stopped: true, bytes: 0, items: 0, path };
76
+ }
77
+ const operationId = crypto.randomUUID();
78
+ const hooks = withParentOperationId(options.telemetry, input.parentOperationId);
79
+ const context: EntryContext = { operationId, hooks, options, type };
80
+
81
+ if (options.directTransfer !== false && source.canDirectTransfer?.(sink)) {
82
+ const direct = type === "move" ? source.directMove : source.directCopy;
83
+ if (direct) {
84
+ await direct.call(source, sourceKey, destKey, { operationId, hooks });
85
+ return { stopped: false, bytes: properties.size ?? 0, items: 0, path: `${path}, direct` };
86
+ }
87
+ }
88
+ if (input.deleteSource && typeof source.delete !== "function") {
89
+ throw new PermanentIOError("The source provider does not support delete, so it cannot move");
90
+ }
91
+
92
+ const retry = options.retry ?? DEFAULT_RETRY;
93
+ let readable = await withRetry(() => source.getReadableStream(sourceKey), retry);
94
+ try {
95
+ checkPayloadType(readable, options);
96
+ if (type === "move" && readable.bounded === false) {
97
+ throw new PermanentIOError("cannot move a live source");
98
+ }
99
+ } catch (error) {
100
+ await cancelStream(readable);
101
+ throw error;
102
+ }
103
+
104
+ let outcome: EntryOutcome;
105
+ let strategy: string;
106
+ const size = properties.size;
107
+ const threshold = options.multipartThreshold ?? DEFAULT_MULTIPART_THRESHOLD;
108
+ const multipartWriter =
109
+ readable.bounded !== false &&
110
+ size !== undefined &&
111
+ size >= threshold &&
112
+ isRangeReadable(readable) &&
113
+ typeof sink.getMultipartWriter === "function"
114
+ ? sink.getMultipartWriter.bind(sink)
115
+ : undefined;
116
+ const partSize =
117
+ multipartWriter && size !== undefined ? negotiatePartSize(source, sink, size) : undefined;
118
+
119
+ if (
120
+ multipartWriter &&
121
+ partSize !== undefined &&
122
+ size !== undefined &&
123
+ isRangeReadable(readable)
124
+ ) {
125
+ await cancelStream(readable);
126
+ const rangeReadable = readable;
127
+ outcome = await withRetry(
128
+ () =>
129
+ multipartTransfer({
130
+ readable: rangeReadable,
131
+ sink,
132
+ writer: multipartWriter(destKey, partSize),
133
+ totalBytes: size,
134
+ partSize,
135
+ limiter: options.concurrencyLimiter ?? defaultConcurrencyLimiter,
136
+ context,
137
+ }),
138
+ retry,
139
+ );
140
+ strategy = "multipart";
141
+ } else {
142
+ let writable = await withRetry(() => sink.getWritableStream(destKey), retry);
143
+ if (
144
+ !options.converter &&
145
+ isFillReadable(readable) &&
146
+ isBufferProvider(writable) &&
147
+ readable.domains.includes(writable.domain)
148
+ ) {
149
+ const depth = leaseDepth(writable, options.leaseDepth);
150
+ let attempt = 0;
151
+ outcome = await withRetry(async () => {
152
+ if (attempt > 0) {
153
+ readable = await source.getReadableStream(sourceKey);
154
+ writable = await sink.getWritableStream(destKey);
155
+ }
156
+ attempt += 1;
157
+ if (!isFillReadable(readable) || !isBufferProvider(writable)) {
158
+ throw new PermanentIOError("Reopened handles no longer support the lease path");
159
+ }
160
+ return leaseTransfer(readable, writable, depth, context);
161
+ }, retry);
162
+ strategy = `lease (depth ${depth})`;
163
+ } else {
164
+ outcome = await streamTransfer({
165
+ source,
166
+ sourceKey,
167
+ readable,
168
+ sink,
169
+ destKey,
170
+ writable,
171
+ totalBytes: size,
172
+ context,
173
+ });
174
+ strategy = "stream";
175
+ }
176
+ }
177
+
178
+ if (input.deleteSource && !outcome.stopped) {
179
+ await withRetry(
180
+ () => (source.delete as (key: string) => Promise<void>).call(source, sourceKey),
181
+ retry,
182
+ );
183
+ }
184
+ return { ...outcome, path: `${path}, ${strategy}` };
185
+ }