@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 CHANGED
@@ -22,8 +22,20 @@
22
22
  bucket).
23
23
  - Otherwise transfers via multipart (when both sides support it and file
24
24
  size crosses a configurable threshold) or plain streaming.
25
+ - Multipart part size is negotiated between source and sink via
26
+ `getPartSizeConstraints` (e.g. reconciling S3's minimum part size and
27
+ 10000-part cap on both ends) - see [Part-size negotiation](#part-size-negotiation).
28
+ - A folder `sourcePath` recurses: a folder-aware `directCopy`/`directMove`
29
+ when the source declares `supportsRecursiveDirectTransfer`, otherwise a
30
+ concurrency-bounded listing-driven transfer following `cp -r` target
31
+ semantics - see [Recursive copy/move](#recursive-copymove).
32
+ - Concurrent multipart parts and recursive entries are bounded by a shared
33
+ `ConcurrencyLimiter` - see [Concurrency](#concurrency).
34
+ - The non-direct transfer path is retried on `TransientIOError` - see
35
+ [Retries](#retries).
25
36
  - Reports progress via a global `TelemetryHooks` callback, tagged with a
26
- per-operation correlation id.
37
+ per-operation correlation id; multipart parts and recursive entries
38
+ report their own child stream tagged with `parentOperationId`.
27
39
  - Stream decorators:
28
40
  - `seekable` wraps a handle that supports `RangeReadable` with a single
29
41
  logical stream whose read position can be jumped via `seek(offset)`,
@@ -69,6 +81,61 @@ The following example project is available:
69
81
  - [flowscripter-io-cli](https://github.com/flowscripter/flowscripter-io-cli) is
70
82
  an example CLI application based on this framework.
71
83
 
84
+ ## Part-size negotiation
85
+
86
+ Before a multipart transfer, `copy()`/`move()` call `getPartSizeConstraints(totalSize)`
87
+ on both `source` and `sink` (when implemented - a missing implementation is
88
+ treated as unconstrained) and reconcile the two into a single part size:
89
+ the larger of the two minimums, clamped to the smaller of the two maximums,
90
+ bumped up if needed so the total number of parts never exceeds the smaller
91
+ of the two `maxParts` (e.g. S3's 10000-part cap on a very large file). If
92
+ the reconciled bounds are mutually infeasible, the transfer falls back to
93
+ plain streaming rather than failing.
94
+
95
+ ## Recursive copy/move
96
+
97
+ When `sourcePath` is a folder, `copy()`/`move()` follow `cp -r`/`mv`
98
+ semantics for the destination: a `destPath` that doesn't exist becomes the
99
+ copy itself; an existing folder gets the source nested inside it as
100
+ `destPath/<source-basename>`; an existing file is rejected.
101
+
102
+ If the source provider declares `supportsRecursiveDirectTransfer: true` and
103
+ is direct-transfer-eligible with the sink, the whole folder is handed to a
104
+ single `directCopy`/`directMove` call. Otherwise the folder is listed
105
+ (`source.list(path, { recursive: true })`) and each entry is transferred
106
+ individually - folders via the optional `createFolder` capability (so empty
107
+ folders are preserved), files via the normal single-file path (which may
108
+ still resolve to a per-file `directCopy`/`directMove`). For a recursive
109
+ move without any direct-transfer capability, every entry is copied first;
110
+ the source folder is deleted as a single recursive `delete()` call only
111
+ after every entry has succeeded.
112
+
113
+ ## Concurrency
114
+
115
+ Multipart parts and recursive-copy/move entries are bounded by a
116
+ `ConcurrencyLimiter` (`TransferOptions.concurrencyLimiter`), defaulting to
117
+ the exported `defaultConcurrencyLimiter` singleton - so two `copy()`/`move()`
118
+ calls in the same process share one cap unless a caller passes its own
119
+ isolated instance:
120
+
121
+ ```typescript
122
+ import { defaultConcurrencyLimiter } from "@flowscripter/pluggable-io-framework";
123
+
124
+ defaultConcurrencyLimiter.setMaxConcurrency(8);
125
+ ```
126
+
127
+ ## Retries
128
+
129
+ The non-direct transfer path (plain streaming, multipart, and the
130
+ copy-then-delete in a non-direct `move()`) is retried via `TransferOptions.retry`
131
+ (`{ maxRetries, backoffMs? }`, defaulting to `{ maxRetries: 3 }`) whenever a
132
+ provider throws `TransientIOError` from `pluggable-io-framework-api`. Any
133
+ other error - including a plain, unwrapped `Error` - is treated as
134
+ non-retryable. Direct provider calls (`directCopy`/`directMove`) are never
135
+ retried by the framework, since backends like the AWS SDK already retry
136
+ internally. The underlying `withRetry` utility is exported standalone for
137
+ other call sites.
138
+
72
139
  ## Development
73
140
 
74
141
  Install dependencies:
@@ -114,12 +181,18 @@ sequenceDiagram
114
181
  ProviderRegistry-->>Host: IOProvider
115
182
 
116
183
  Host->>Source: copy(source, path, sink, path)
117
- alt canDirectTransfer
184
+ alt path is a folder
185
+ alt supportsRecursiveDirectTransfer
186
+ Source->>Sink: directCopy(folderPath, folderPath)
187
+ else
188
+ Source-->>Sink: list + transfer entries (bounded by ConcurrencyLimiter)
189
+ end
190
+ else canDirectTransfer
118
191
  Source->>Sink: directCopy(path, path)
119
- else multipart eligible
120
- Source-->>Sink: transfer Parts concurrently
192
+ else multipart eligible (negotiated part size)
193
+ Source-->>Sink: transfer Parts concurrently (bounded, retried on TransientIOError)
121
194
  else
122
- Source-->>Sink: stream ChunkRefs
195
+ Source-->>Sink: stream ChunkRefs (retried on TransientIOError)
123
196
  end
124
197
  ```
125
198
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
+ export * from "./src/ConcurrencyLimiter.ts";
1
2
  export * from "./src/ProviderRegistry.ts";
2
3
  export * from "./src/copyMove.ts";
3
4
  export * from "./src/decorators/locallyCached.ts";
4
5
  export * from "./src/decorators/seekable.ts";
6
+ export * from "./src/withRetry.ts";
5
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAC;AAC1C,cAAc,mBAAmB,CAAC;AAClC,cAAc,mCAAmC,CAAC;AAClD,cAAc,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,mBAAmB,CAAC;AAClC,cAAc,mCAAmC,CAAC;AAClD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
+ export * from "./src/ConcurrencyLimiter.js";
1
2
  export * from "./src/ProviderRegistry.js";
2
3
  export * from "./src/copyMove.js";
3
4
  export * from "./src/decorators/locallyCached.js";
4
5
  export * from "./src/decorators/seekable.js";
6
+ export * from "./src/withRetry.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A semaphore bounding how many `run()` callbacks execute at once. A single
3
+ * shared instance (see {@link defaultConcurrencyLimiter}) enforces its cap
4
+ * *globally* across concurrently invoked `copy()`/`move()` calls, not just
5
+ * within one call - two folder copies started at the same time and sharing
6
+ * a limiter will never together exceed its `maxConcurrency`.
7
+ */
8
+ export declare class ConcurrencyLimiter {
9
+ #private;
10
+ constructor(maxConcurrency: number);
11
+ get maxConcurrency(): number;
12
+ setMaxConcurrency(maxConcurrency: number): void;
13
+ run<T>(fn: () => Promise<T>): Promise<T>;
14
+ }
15
+ /**
16
+ * Process-wide default limiter, used by `copy()`/`move()` whenever
17
+ * `TransferOptions.concurrencyLimiter` is omitted - so any two calls in the
18
+ * same process share one cap unless a caller explicitly opts out with its
19
+ * own instance (e.g. for test isolation).
20
+ */
21
+ export declare const defaultConcurrencyLimiter: ConcurrencyLimiter;
22
+ /**
23
+ * Pulls items from `source` and runs `fn` on each, bounded by `limiter`:
24
+ * exactly `limiter.maxConcurrency` worker loops each pull-then-process one
25
+ * item at a time through `limiter.run`, so at most that many items are ever
26
+ * pulled/in-flight at once (important for sources like a multipart reader
27
+ * where pulling an item eagerly opens a file/network handle). Results are
28
+ * yielded in completion order, not source order. If `fn` throws, no new
29
+ * items are pulled but already-in-flight ones are allowed to finish before
30
+ * the error is rethrown from the returned iterable.
31
+ */
32
+ export declare function mapAsyncIterableConcurrently<T, R>(source: AsyncIterable<T>, fn: (item: T) => Promise<R>, limiter: ConcurrencyLimiter): AsyncIterable<R>;
33
+ //# sourceMappingURL=ConcurrencyLimiter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ConcurrencyLimiter.d.ts","sourceRoot":"","sources":["../../src/ConcurrencyLimiter.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,qBAAa,kBAAkB;;IAK7B,YAAmB,cAAc,EAAE,MAAM,EAGxC;IAED,IAAW,cAAc,IAAI,MAAM,CAElC;IAEM,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAIrD;IAEY,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAQpD;CA0BF;AAED;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,oBAAkD,CAAC;AAuDzF;;;;;;;;;GASG;AACH,wBAAgB,4BAA4B,CAAC,CAAC,EAAE,CAAC,EAC/C,MAAM,EAAE,aAAa,CAAC,CAAC,CAAC,EACxB,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAC3B,OAAO,EAAE,kBAAkB,GAC1B,aAAa,CAAC,CAAC,CAAC,CAqClB"}
@@ -0,0 +1,161 @@
1
+ const DEFAULT_MAX_CONCURRENCY = 4;
2
+ /**
3
+ * A semaphore bounding how many `run()` callbacks execute at once. A single
4
+ * shared instance (see {@link defaultConcurrencyLimiter}) enforces its cap
5
+ * *globally* across concurrently invoked `copy()`/`move()` calls, not just
6
+ * within one call - two folder copies started at the same time and sharing
7
+ * a limiter will never together exceed its `maxConcurrency`.
8
+ */
9
+ export class ConcurrencyLimiter {
10
+ #maxConcurrency;
11
+ #active = 0;
12
+ #queue = [];
13
+ constructor(maxConcurrency) {
14
+ ConcurrencyLimiter.#validate(maxConcurrency);
15
+ this.#maxConcurrency = maxConcurrency;
16
+ }
17
+ get maxConcurrency() {
18
+ return this.#maxConcurrency;
19
+ }
20
+ setMaxConcurrency(maxConcurrency) {
21
+ ConcurrencyLimiter.#validate(maxConcurrency);
22
+ this.#maxConcurrency = maxConcurrency;
23
+ this.#drain();
24
+ }
25
+ async run(fn) {
26
+ await this.#acquire();
27
+ try {
28
+ return await fn();
29
+ }
30
+ finally {
31
+ this.#active -= 1;
32
+ this.#drain();
33
+ }
34
+ }
35
+ #acquire() {
36
+ if (this.#active < this.#maxConcurrency) {
37
+ this.#active += 1;
38
+ return Promise.resolve();
39
+ }
40
+ return new Promise((resolve) => {
41
+ this.#queue.push(() => {
42
+ this.#active += 1;
43
+ resolve();
44
+ });
45
+ });
46
+ }
47
+ #drain() {
48
+ while (this.#active < this.#maxConcurrency && this.#queue.length > 0) {
49
+ this.#queue.shift()?.();
50
+ }
51
+ }
52
+ static #validate(maxConcurrency) {
53
+ if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) {
54
+ throw new Error(`maxConcurrency must be a positive integer, got ${maxConcurrency}`);
55
+ }
56
+ }
57
+ }
58
+ /**
59
+ * Process-wide default limiter, used by `copy()`/`move()` whenever
60
+ * `TransferOptions.concurrencyLimiter` is omitted - so any two calls in the
61
+ * same process share one cap unless a caller explicitly opts out with its
62
+ * own instance (e.g. for test isolation).
63
+ */
64
+ export const defaultConcurrencyLimiter = new ConcurrencyLimiter(DEFAULT_MAX_CONCURRENCY);
65
+ /** Minimal single-consumer async FIFO queue backing {@link mapAsyncIterableConcurrently}. */
66
+ class AsyncChannel {
67
+ #items = [];
68
+ #waiters = [];
69
+ #closed = false;
70
+ #hasError = false;
71
+ #error;
72
+ push(item) {
73
+ const waiter = this.#waiters.shift();
74
+ if (waiter) {
75
+ waiter.resolve({ value: item, done: false });
76
+ }
77
+ else {
78
+ this.#items.push(item);
79
+ }
80
+ }
81
+ close() {
82
+ this.#closed = true;
83
+ for (const waiter of this.#waiters.splice(0)) {
84
+ if (this.#hasError) {
85
+ waiter.reject(this.#error);
86
+ }
87
+ else {
88
+ waiter.resolve({ value: undefined, done: true });
89
+ }
90
+ }
91
+ }
92
+ fail(error) {
93
+ this.#hasError = true;
94
+ this.#error = error;
95
+ this.close();
96
+ }
97
+ async #next() {
98
+ if (this.#items.length > 0) {
99
+ return { value: this.#items.shift(), done: false };
100
+ }
101
+ if (this.#closed) {
102
+ if (this.#hasError) {
103
+ throw this.#error;
104
+ }
105
+ return { value: undefined, done: true };
106
+ }
107
+ return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
108
+ }
109
+ [Symbol.asyncIterator]() {
110
+ return { next: () => this.#next() };
111
+ }
112
+ }
113
+ /**
114
+ * Pulls items from `source` and runs `fn` on each, bounded by `limiter`:
115
+ * exactly `limiter.maxConcurrency` worker loops each pull-then-process one
116
+ * item at a time through `limiter.run`, so at most that many items are ever
117
+ * pulled/in-flight at once (important for sources like a multipart reader
118
+ * where pulling an item eagerly opens a file/network handle). Results are
119
+ * yielded in completion order, not source order. If `fn` throws, no new
120
+ * items are pulled but already-in-flight ones are allowed to finish before
121
+ * the error is rethrown from the returned iterable.
122
+ */
123
+ export function mapAsyncIterableConcurrently(source, fn, limiter) {
124
+ const channel = new AsyncChannel();
125
+ const iterator = source[Symbol.asyncIterator]();
126
+ let stopped = false;
127
+ let firstError;
128
+ let errorRecorded = false;
129
+ async function worker() {
130
+ for (;;) {
131
+ if (stopped)
132
+ return;
133
+ const { done, value } = await iterator.next();
134
+ if (done)
135
+ return;
136
+ try {
137
+ const result = await limiter.run(() => fn(value));
138
+ channel.push(result);
139
+ }
140
+ catch (error) {
141
+ stopped = true;
142
+ if (!errorRecorded) {
143
+ errorRecorded = true;
144
+ firstError = error;
145
+ }
146
+ return;
147
+ }
148
+ }
149
+ }
150
+ void (async () => {
151
+ const workerCount = Math.max(1, limiter.maxConcurrency);
152
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
153
+ if (errorRecorded) {
154
+ channel.fail(firstError);
155
+ }
156
+ else {
157
+ channel.close();
158
+ }
159
+ })();
160
+ return channel;
161
+ }
@@ -1,4 +1,6 @@
1
1
  import { type ChunkConverter, type IOProvider, type TelemetryHooks } from "@flowscripter/pluggable-io-framework-api";
2
+ import { type ConcurrencyLimiter } from "./ConcurrencyLimiter.ts";
3
+ import { type RetryOptions } from "./withRetry.ts";
2
4
  export interface TransferOptions {
3
5
  readonly telemetry?: TelemetryHooks;
4
6
  /** Minimum file size (bytes) before multipart transfer is attempted over plain streaming. */
@@ -10,20 +12,35 @@ export interface TransferOptions {
10
12
  * FFI-capable converter supplied by a runtime-specific package.
11
13
  */
12
14
  readonly chunkConverter?: ChunkConverter;
15
+ /**
16
+ * Bounds concurrent multipart parts and recursive-copy/move entries.
17
+ * Defaults to the shared {@link defaultConcurrencyLimiter} - so two
18
+ * `copy()`/`move()` calls in the same process share one cap unless a
19
+ * caller passes its own isolated instance.
20
+ */
21
+ readonly concurrencyLimiter?: ConcurrencyLimiter;
22
+ /** Retry policy for the non-direct transfer path. Defaults to `{ maxRetries: 3 }`. */
23
+ readonly retry?: RetryOptions;
13
24
  }
14
25
  /**
15
- * Copies `sourcePath` on `source` to `destPath` on `sink`.
26
+ * Copies `sourcePath` on `source` to `destPath` on `sink`. When `sourcePath`
27
+ * is a folder, recurses: uses a folder-aware `directCopy` when the source
28
+ * declares `supportsRecursiveDirectTransfer`, otherwise lists the folder and
29
+ * transfers each entry (bounded by `options.concurrencyLimiter`), following
30
+ * `cp -r` target semantics (see {@link resolveRecursiveDestRoot}).
16
31
  *
17
- * Uses `source.directCopy` when `source.canDirectTransfer?.(sink)` reports
18
- * eligibility (same-provider direct transfer). Otherwise falls back to
19
- * multipart transfer (when both sides support it and size crosses
20
- * `options.multipartThreshold`) or plain streaming.
32
+ * For a single file: uses `source.directCopy` when
33
+ * `source.canDirectTransfer?.(sink)` reports eligibility. Otherwise falls
34
+ * back to multipart transfer (when both sides support it, size crosses
35
+ * `options.multipartThreshold`, and part-size negotiation succeeds) or plain
36
+ * streaming - retried per `options.retry` on `TransientIOError`.
21
37
  */
22
38
  export declare function copy(source: IOProvider, sourcePath: string, sink: IOProvider, destPath: string, options?: TransferOptions): Promise<void>;
23
39
  /**
24
- * Moves `sourcePath` on `source` to `destPath` on `sink`. Uses
25
- * `source.directMove` when eligible, otherwise performs a {@link copy}
26
- * followed by deleting the source item.
40
+ * Moves `sourcePath` on `source` to `destPath` on `sink`. Folder handling
41
+ * mirrors {@link copy}. For a single file, uses `source.directMove` when
42
+ * eligible, otherwise performs a {@link copy} followed by deleting the
43
+ * source item (each retried independently per `options.retry`).
27
44
  */
28
45
  export declare function move(source: IOProvider, sourcePath: string, sink: IOProvider, destPath: string, options?: TransferOptions): Promise<void>;
29
46
  //# sourceMappingURL=copyMove.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"copyMove.d.ts","sourceRoot":"","sources":["../../src/copyMove.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,cAAc,EAEnB,KAAK,UAAU,EAEf,KAAK,cAAc,EACpB,MAAM,0CAA0C,CAAC;AAGlD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,cAAc,CAAC;IACpC,6FAA6F;IAC7F,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,CAAC;CAC1C;AAoGD;;;;;;;GAOG;AACH,wBAAsB,IAAI,CACxB,MAAM,EAAE,UAAU,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED;;;;GAIG;AACH,wBAAsB,IAAI,CACxB,MAAM,EAAE,UAAU,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,IAAI,CAAC,CAOf"}
1
+ {"version":3,"file":"copyMove.d.ts","sourceRoot":"","sources":["../../src/copyMove.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,cAAc,EAEnB,KAAK,UAAU,EAIf,KAAK,cAAc,EACpB,MAAM,0CAA0C,CAAC;AAElD,OAAO,EACL,KAAK,kBAAkB,EAGxB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAa,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAE9D,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,cAAc,CAAC;IACpC,6FAA6F;IAC7F,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,CAAC;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACjD,sFAAsF;IACtF,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;CAC/B;AA+WD;;;;;;;;;;;;GAYG;AACH,wBAAsB,IAAI,CACxB,MAAM,EAAE,UAAU,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;;;GAKG;AACH,wBAAsB,IAAI,CACxB,MAAM,EAAE,UAAU,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,UAAU,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,eAAoB,GAC5B,OAAO,CAAC,IAAI,CAAC,CAOf"}