@statewalker/webrun-files-s3 0.10.4 → 0.11.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 +28 -4
- package/dist/cjs/index.cjs +66 -9
- package/dist/esm/index.js +67 -10
- package/dist/s3-files-api.d.ts +28 -0
- package/dist/s3-files-api.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/s3-files-api.ts +85 -6
package/README.md
CHANGED
|
@@ -83,6 +83,20 @@ for await (const chunk of files.read('/data/large.bin', { start: 1000, length: 5
|
|
|
83
83
|
}
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
+
### A lock object, shared by every client of the bucket
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
const locked = await files.createExclusive('/jobs/rebuild.lock', [new TextEncoder().encode(host)]);
|
|
90
|
+
if (!locked) return; // another client created it first
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`createExclusive` sends the request that makes the object visible, `PutObject` or, above
|
|
94
|
+
5 MiB, `CompleteMultipartUpload`, with `If-None-Match: *`. The service creates the key only if no
|
|
95
|
+
object has it and answers `412 Precondition Failed` otherwise, which the method returns as
|
|
96
|
+
`false`. Every client of the bucket is excluded. AWS S3 has supported conditional writes since
|
|
97
|
+
August 2024, and RustFS, which the integration tests use, honours them. A service that ignores
|
|
98
|
+
the header gives no exclusion at all: check yours before relying on it.
|
|
99
|
+
|
|
86
100
|
## Internals
|
|
87
101
|
|
|
88
102
|
### How calls map to S3
|
|
@@ -95,6 +109,7 @@ path "/docs/file.txt" + prefix "my-app/data" -> key "my-app/data/docs/file.txt
|
|
|
95
109
|
| --- | --- |
|
|
96
110
|
| `read` | `GetObject`, with `Range: bytes=start-end` when `start`/`length` are given; streams the body |
|
|
97
111
|
| `write` | under 5 MiB: one `PutObject`; otherwise `CreateMultipartUpload` + one `UploadPart` per `multipartPartSize` + `CompleteMultipartUpload`; `AbortMultipartUpload` on any error |
|
|
112
|
+
| `createExclusive` | a `stats` check (any object or prefix at the path returns `false`), then as `write`, with `If-None-Match: *` on the final `PutObject` or `CompleteMultipartUpload`; `412` returns `false`, a `409 ConditionalRequestConflict` (another conditional write in flight) is retried up to four times |
|
|
98
113
|
| `mkdir` | `PutObject` of an empty `dir/` marker key |
|
|
99
114
|
| `list` | `ListObjectsV2` with `Delimiter: "/"` (or none when recursive), `StartAfter` for `after` |
|
|
100
115
|
| `copy` | `CopyObject` per object, server-side |
|
|
@@ -124,10 +139,17 @@ under `a/` both exist, `/a` is listed once, as the file, the same answer `stats(
|
|
|
124
139
|
|
|
125
140
|
### What breaks, and how it looks
|
|
126
141
|
|
|
127
|
-
- **`move` and directory `copy` are not atomic.**
|
|
128
|
-
calls; a failure midway leaves some objects copied and the source partly
|
|
142
|
+
- **`move` and directory `copy` are not atomic.** S3 has no rename: they are many `CopyObject`
|
|
143
|
+
and `DeleteObject` calls; a failure midway leaves some objects copied and the source partly
|
|
144
|
+
removed. When the copy succeeded but the source cannot be deleted, `move` retries the delete
|
|
145
|
+
once, then throws a `MoveIncompleteError` (`move /a -> /b: the target was replaced, but the
|
|
146
|
+
source could not be removed`) instead of returning `false`.
|
|
129
147
|
- **Writing zero chunks creates nothing.** `write(path, [])` sends no request, so the path does not
|
|
130
148
|
exist afterwards. Pass `[new Uint8Array(0)]` to create an empty file.
|
|
149
|
+
- **Only the key is exclusive.** A directory is a key prefix, so an existing one makes
|
|
150
|
+
`createExclusive` return `false` through the check before the upload, but a directory created
|
|
151
|
+
concurrently with the call is not excluded. Unlike `write`, `createExclusive(path, [])`
|
|
152
|
+
creates an empty object.
|
|
131
153
|
- **Single-request copies.** Each object is copied with one `CopyObject`, which S3 limits to 5 GB
|
|
132
154
|
per object.
|
|
133
155
|
- **Errors other than not-found propagate.** `read` treats `404` and `416` as an empty read; any
|
|
@@ -135,11 +157,13 @@ under `a/` both exist, `/a` is listed once, as the file, the same answer `stats(
|
|
|
135
157
|
|
|
136
158
|
### Testing
|
|
137
159
|
|
|
138
|
-
`pnpm test` runs only unit tests
|
|
160
|
+
`pnpm test` runs only unit tests, against fake clients: listing order across pages, and how
|
|
161
|
+
`createExclusive` handles `412` (returns `false`, also from `CompleteMultipartUpload`) and `409`
|
|
162
|
+
(retried, then thrown).
|
|
139
163
|
`pnpm test:integration` needs Docker: it starts
|
|
140
164
|
[RustFS](https://github.com/rustfs/rustfs) through testcontainers (`rustfs/rustfs:latest`, override
|
|
141
165
|
with `RUSTFS_IMAGE`) and runs the shared `createFilesApiTests` and `createBigFilesApiTests` suites
|
|
142
|
-
against it.
|
|
166
|
+
against it, the `createExclusive` cases included.
|
|
143
167
|
|
|
144
168
|
### Dependencies
|
|
145
169
|
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
|
|
3
3
|
let _statewalker_webrun_files = require("@statewalker/webrun-files");
|
|
4
|
-
|
|
4
|
+
// src/s3-files-api.ts
|
|
5
5
|
/** Default part size for multipart uploads: 5MB (S3 minimum) */
|
|
6
6
|
const DEFAULT_PART_SIZE = 5242880;
|
|
7
7
|
/** Threshold for using multipart upload: 5MB */
|
|
@@ -62,14 +62,40 @@ var S3FilesApi = class {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
async write(path, content) {
|
|
65
|
-
|
|
65
|
+
await this.upload(this.resolveKey(path), content, false);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A conditional write, `If-None-Match: *`, on the `PutObject` (or, above the multipart
|
|
69
|
+
* threshold, on the `CompleteMultipartUpload`) that makes the object visible: the
|
|
70
|
+
* service creates the key only if no object has it, and answers 412 otherwise. That
|
|
71
|
+
* excludes every client of the bucket. AWS S3 supports it since August 2024; an
|
|
72
|
+
* S3-compatible service that ignores the header gives no exclusion at all, so check
|
|
73
|
+
* yours before relying on this.
|
|
74
|
+
*
|
|
75
|
+
* Only the key itself is exclusive. A directory is a key prefix, so an existing one is
|
|
76
|
+
* turned away by a check before the upload, but one created concurrently with this
|
|
77
|
+
* call is not excluded. Nothing is visible until the content is complete; a failing
|
|
78
|
+
* source aborts the multipart upload and leaves no object.
|
|
79
|
+
*/
|
|
80
|
+
async createExclusive(path, content) {
|
|
81
|
+
if ((0, _statewalker_webrun_files.normalizePath)(path) === "/" || await this.stats(path)) return false;
|
|
82
|
+
return this.upload(this.resolveKey(path), content, true);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Upload `content` to `key`: one PutObject below the multipart threshold, else a
|
|
86
|
+
* multipart upload. With `exclusive`, the request that creates the object carries
|
|
87
|
+
* `If-None-Match: *`; returns false when the service refuses it because the key exists.
|
|
88
|
+
*/
|
|
89
|
+
async upload(key, content, exclusive) {
|
|
90
|
+
const ifNoneMatch = exclusive ? "*" : void 0;
|
|
91
|
+
const sendLast = (send) => exclusive ? sendConditional(send) : send();
|
|
66
92
|
let buffer = [];
|
|
67
93
|
let bufferSize = 0;
|
|
68
94
|
let uploadId;
|
|
69
95
|
const parts = [];
|
|
70
96
|
let partNumber = 1;
|
|
71
97
|
const flushPart = async (isLast) => {
|
|
72
|
-
if (buffer.length === 0) return;
|
|
98
|
+
if (buffer.length === 0 && !(exclusive && isLast && !uploadId)) return;
|
|
73
99
|
const partLength = buffer.reduce((sum, c) => sum + c.length, 0);
|
|
74
100
|
const partBuffer = new Uint8Array(partLength);
|
|
75
101
|
let offset = 0;
|
|
@@ -84,9 +110,10 @@ var S3FilesApi = class {
|
|
|
84
110
|
Bucket: this.bucket,
|
|
85
111
|
Key: key,
|
|
86
112
|
Body: partBuffer,
|
|
87
|
-
ContentLength: partLength
|
|
113
|
+
ContentLength: partLength,
|
|
114
|
+
IfNoneMatch: ifNoneMatch
|
|
88
115
|
});
|
|
89
|
-
await this.client.send(command);
|
|
116
|
+
await sendLast(() => this.client.send(command));
|
|
90
117
|
return;
|
|
91
118
|
}
|
|
92
119
|
if (!uploadId) {
|
|
@@ -125,10 +152,12 @@ var S3FilesApi = class {
|
|
|
125
152
|
Bucket: this.bucket,
|
|
126
153
|
Key: key,
|
|
127
154
|
UploadId: uploadId,
|
|
128
|
-
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) }
|
|
155
|
+
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
|
|
156
|
+
IfNoneMatch: ifNoneMatch
|
|
129
157
|
});
|
|
130
|
-
await this.client.send(completeCommand);
|
|
158
|
+
await sendLast(() => this.client.send(completeCommand));
|
|
131
159
|
}
|
|
160
|
+
return true;
|
|
132
161
|
} catch (error) {
|
|
133
162
|
if (uploadId) {
|
|
134
163
|
const abortCommand = new _aws_sdk_client_s3.AbortMultipartUploadCommand({
|
|
@@ -140,6 +169,7 @@ var S3FilesApi = class {
|
|
|
140
169
|
await this.client.send(abortCommand);
|
|
141
170
|
} catch {}
|
|
142
171
|
}
|
|
172
|
+
if (exclusive && isPreconditionFailed(error)) return false;
|
|
143
173
|
throw error;
|
|
144
174
|
}
|
|
145
175
|
}
|
|
@@ -316,9 +346,18 @@ var S3FilesApi = class {
|
|
|
316
346
|
}
|
|
317
347
|
return true;
|
|
318
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* S3 has no rename, so a move is a copy, then a delete, and is NOT atomic: while it
|
|
351
|
+
* runs, the object exists under both keys, and a directory (a key prefix) moves object
|
|
352
|
+
* by object. A failed copy throws and keeps the source. If the copy succeeds but the
|
|
353
|
+
* source cannot be deleted, the delete is retried once, then a `MoveIncompleteError`
|
|
354
|
+
* is thrown: the target holds the new content and the source still exists. False is
|
|
355
|
+
* never returned for a half-done move.
|
|
356
|
+
*/
|
|
319
357
|
async move(source, target) {
|
|
320
358
|
if (!await this.copy(source, target)) return false;
|
|
321
|
-
|
|
359
|
+
await (0, _statewalker_webrun_files.removeMovedSource)(this, (0, _statewalker_webrun_files.normalizePath)(source), (0, _statewalker_webrun_files.normalizePath)(target));
|
|
360
|
+
return true;
|
|
322
361
|
}
|
|
323
362
|
async copy(source, target) {
|
|
324
363
|
const sourceKey = this.resolveKey(source);
|
|
@@ -377,5 +416,23 @@ function insertInOrder(entries, entry) {
|
|
|
377
416
|
while (i > 0 && (0, _statewalker_webrun_files.comparePaths)(entries[i - 1].path, entry.path) > 0) i--;
|
|
378
417
|
entries.splice(i, 0, entry);
|
|
379
418
|
}
|
|
380
|
-
|
|
419
|
+
/** The service refused a conditional write because the key exists (HTTP 412). */
|
|
420
|
+
function isPreconditionFailed(error) {
|
|
421
|
+
const err = error;
|
|
422
|
+
return err?.name === "PreconditionFailed" || err?.$metadata?.httpStatusCode === 412;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Send a request that may carry `If-None-Match`, retrying a 409
|
|
426
|
+
* `ConditionalRequestConflict`: S3 answers that while another conditional write to the
|
|
427
|
+
* key is in flight, and asks the client to retry, after which one of them gets 412.
|
|
428
|
+
*/
|
|
429
|
+
async function sendConditional(send) {
|
|
430
|
+
for (let attempt = 0;; attempt++) try {
|
|
431
|
+
return await send();
|
|
432
|
+
} catch (error) {
|
|
433
|
+
const err = error;
|
|
434
|
+
if (!(err?.name === "ConditionalRequestConflict" || err?.$metadata?.httpStatusCode === 409) || attempt >= 4) throw error;
|
|
435
|
+
await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
381
438
|
exports.S3FilesApi = S3FilesApi;
|
package/dist/esm/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CopyObjectCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, UploadPartCommand } from "@aws-sdk/client-s3";
|
|
2
|
-
import { basename, comparePaths, normalizePath } from "@statewalker/webrun-files";
|
|
3
|
-
|
|
2
|
+
import { basename, comparePaths, normalizePath, removeMovedSource } from "@statewalker/webrun-files";
|
|
3
|
+
// src/s3-files-api.ts
|
|
4
4
|
/** Default part size for multipart uploads: 5MB (S3 minimum) */
|
|
5
5
|
const DEFAULT_PART_SIZE = 5242880;
|
|
6
6
|
/** Threshold for using multipart upload: 5MB */
|
|
@@ -61,14 +61,40 @@ var S3FilesApi = class {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
async write(path, content) {
|
|
64
|
-
|
|
64
|
+
await this.upload(this.resolveKey(path), content, false);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A conditional write, `If-None-Match: *`, on the `PutObject` (or, above the multipart
|
|
68
|
+
* threshold, on the `CompleteMultipartUpload`) that makes the object visible: the
|
|
69
|
+
* service creates the key only if no object has it, and answers 412 otherwise. That
|
|
70
|
+
* excludes every client of the bucket. AWS S3 supports it since August 2024; an
|
|
71
|
+
* S3-compatible service that ignores the header gives no exclusion at all, so check
|
|
72
|
+
* yours before relying on this.
|
|
73
|
+
*
|
|
74
|
+
* Only the key itself is exclusive. A directory is a key prefix, so an existing one is
|
|
75
|
+
* turned away by a check before the upload, but one created concurrently with this
|
|
76
|
+
* call is not excluded. Nothing is visible until the content is complete; a failing
|
|
77
|
+
* source aborts the multipart upload and leaves no object.
|
|
78
|
+
*/
|
|
79
|
+
async createExclusive(path, content) {
|
|
80
|
+
if (normalizePath(path) === "/" || await this.stats(path)) return false;
|
|
81
|
+
return this.upload(this.resolveKey(path), content, true);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Upload `content` to `key`: one PutObject below the multipart threshold, else a
|
|
85
|
+
* multipart upload. With `exclusive`, the request that creates the object carries
|
|
86
|
+
* `If-None-Match: *`; returns false when the service refuses it because the key exists.
|
|
87
|
+
*/
|
|
88
|
+
async upload(key, content, exclusive) {
|
|
89
|
+
const ifNoneMatch = exclusive ? "*" : void 0;
|
|
90
|
+
const sendLast = (send) => exclusive ? sendConditional(send) : send();
|
|
65
91
|
let buffer = [];
|
|
66
92
|
let bufferSize = 0;
|
|
67
93
|
let uploadId;
|
|
68
94
|
const parts = [];
|
|
69
95
|
let partNumber = 1;
|
|
70
96
|
const flushPart = async (isLast) => {
|
|
71
|
-
if (buffer.length === 0) return;
|
|
97
|
+
if (buffer.length === 0 && !(exclusive && isLast && !uploadId)) return;
|
|
72
98
|
const partLength = buffer.reduce((sum, c) => sum + c.length, 0);
|
|
73
99
|
const partBuffer = new Uint8Array(partLength);
|
|
74
100
|
let offset = 0;
|
|
@@ -83,9 +109,10 @@ var S3FilesApi = class {
|
|
|
83
109
|
Bucket: this.bucket,
|
|
84
110
|
Key: key,
|
|
85
111
|
Body: partBuffer,
|
|
86
|
-
ContentLength: partLength
|
|
112
|
+
ContentLength: partLength,
|
|
113
|
+
IfNoneMatch: ifNoneMatch
|
|
87
114
|
});
|
|
88
|
-
await this.client.send(command);
|
|
115
|
+
await sendLast(() => this.client.send(command));
|
|
89
116
|
return;
|
|
90
117
|
}
|
|
91
118
|
if (!uploadId) {
|
|
@@ -124,10 +151,12 @@ var S3FilesApi = class {
|
|
|
124
151
|
Bucket: this.bucket,
|
|
125
152
|
Key: key,
|
|
126
153
|
UploadId: uploadId,
|
|
127
|
-
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) }
|
|
154
|
+
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
|
|
155
|
+
IfNoneMatch: ifNoneMatch
|
|
128
156
|
});
|
|
129
|
-
await this.client.send(completeCommand);
|
|
157
|
+
await sendLast(() => this.client.send(completeCommand));
|
|
130
158
|
}
|
|
159
|
+
return true;
|
|
131
160
|
} catch (error) {
|
|
132
161
|
if (uploadId) {
|
|
133
162
|
const abortCommand = new AbortMultipartUploadCommand({
|
|
@@ -139,6 +168,7 @@ var S3FilesApi = class {
|
|
|
139
168
|
await this.client.send(abortCommand);
|
|
140
169
|
} catch {}
|
|
141
170
|
}
|
|
171
|
+
if (exclusive && isPreconditionFailed(error)) return false;
|
|
142
172
|
throw error;
|
|
143
173
|
}
|
|
144
174
|
}
|
|
@@ -315,9 +345,18 @@ var S3FilesApi = class {
|
|
|
315
345
|
}
|
|
316
346
|
return true;
|
|
317
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* S3 has no rename, so a move is a copy, then a delete, and is NOT atomic: while it
|
|
350
|
+
* runs, the object exists under both keys, and a directory (a key prefix) moves object
|
|
351
|
+
* by object. A failed copy throws and keeps the source. If the copy succeeds but the
|
|
352
|
+
* source cannot be deleted, the delete is retried once, then a `MoveIncompleteError`
|
|
353
|
+
* is thrown: the target holds the new content and the source still exists. False is
|
|
354
|
+
* never returned for a half-done move.
|
|
355
|
+
*/
|
|
318
356
|
async move(source, target) {
|
|
319
357
|
if (!await this.copy(source, target)) return false;
|
|
320
|
-
|
|
358
|
+
await removeMovedSource(this, normalizePath(source), normalizePath(target));
|
|
359
|
+
return true;
|
|
321
360
|
}
|
|
322
361
|
async copy(source, target) {
|
|
323
362
|
const sourceKey = this.resolveKey(source);
|
|
@@ -376,5 +415,23 @@ function insertInOrder(entries, entry) {
|
|
|
376
415
|
while (i > 0 && comparePaths(entries[i - 1].path, entry.path) > 0) i--;
|
|
377
416
|
entries.splice(i, 0, entry);
|
|
378
417
|
}
|
|
379
|
-
|
|
418
|
+
/** The service refused a conditional write because the key exists (HTTP 412). */
|
|
419
|
+
function isPreconditionFailed(error) {
|
|
420
|
+
const err = error;
|
|
421
|
+
return err?.name === "PreconditionFailed" || err?.$metadata?.httpStatusCode === 412;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Send a request that may carry `If-None-Match`, retrying a 409
|
|
425
|
+
* `ConditionalRequestConflict`: S3 answers that while another conditional write to the
|
|
426
|
+
* key is in flight, and asks the client to retry, after which one of them gets 412.
|
|
427
|
+
*/
|
|
428
|
+
async function sendConditional(send) {
|
|
429
|
+
for (let attempt = 0;; attempt++) try {
|
|
430
|
+
return await send();
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const err = error;
|
|
433
|
+
if (!(err?.name === "ConditionalRequestConflict" || err?.$metadata?.httpStatusCode === 409) || attempt >= 4) throw error;
|
|
434
|
+
await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
380
437
|
export { S3FilesApi };
|
package/dist/s3-files-api.d.ts
CHANGED
|
@@ -42,6 +42,26 @@ export declare class S3FilesApi implements FilesApi {
|
|
|
42
42
|
private keyToPath;
|
|
43
43
|
read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
|
|
44
44
|
write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* A conditional write, `If-None-Match: *`, on the `PutObject` (or, above the multipart
|
|
47
|
+
* threshold, on the `CompleteMultipartUpload`) that makes the object visible: the
|
|
48
|
+
* service creates the key only if no object has it, and answers 412 otherwise. That
|
|
49
|
+
* excludes every client of the bucket. AWS S3 supports it since August 2024; an
|
|
50
|
+
* S3-compatible service that ignores the header gives no exclusion at all, so check
|
|
51
|
+
* yours before relying on this.
|
|
52
|
+
*
|
|
53
|
+
* Only the key itself is exclusive. A directory is a key prefix, so an existing one is
|
|
54
|
+
* turned away by a check before the upload, but one created concurrently with this
|
|
55
|
+
* call is not excluded. Nothing is visible until the content is complete; a failing
|
|
56
|
+
* source aborts the multipart upload and leaves no object.
|
|
57
|
+
*/
|
|
58
|
+
createExclusive(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
|
|
59
|
+
/**
|
|
60
|
+
* Upload `content` to `key`: one PutObject below the multipart threshold, else a
|
|
61
|
+
* multipart upload. With `exclusive`, the request that creates the object carries
|
|
62
|
+
* `If-None-Match: *`; returns false when the service refuses it because the key exists.
|
|
63
|
+
*/
|
|
64
|
+
private upload;
|
|
45
65
|
mkdir(path: string): Promise<void>;
|
|
46
66
|
/**
|
|
47
67
|
* Keys list in UTF-8 byte order, which is `comparePaths` order for files.
|
|
@@ -54,6 +74,14 @@ export declare class S3FilesApi implements FilesApi {
|
|
|
54
74
|
stats(path: string): Promise<FileStats | undefined>;
|
|
55
75
|
exists(path: string): Promise<boolean>;
|
|
56
76
|
remove(path: string): Promise<boolean>;
|
|
77
|
+
/**
|
|
78
|
+
* S3 has no rename, so a move is a copy, then a delete, and is NOT atomic: while it
|
|
79
|
+
* runs, the object exists under both keys, and a directory (a key prefix) moves object
|
|
80
|
+
* by object. A failed copy throws and keeps the source. If the copy succeeds but the
|
|
81
|
+
* source cannot be deleted, the delete is retried once, then a `MoveIncompleteError`
|
|
82
|
+
* is thrown: the target holds the new content and the source still exists. False is
|
|
83
|
+
* never returned for a half-done move.
|
|
84
|
+
*/
|
|
57
85
|
move(source: string, target: string): Promise<boolean>;
|
|
58
86
|
copy(source: string, target: string): Promise<boolean>;
|
|
59
87
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"s3-files-api.d.ts","sourceRoot":"","sources":["../src/s3-files-api.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAanD,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"s3-files-api.d.ts","sourceRoot":"","sources":["../src/s3-files-api.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAanD,OAAO,KAAK,EACV,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACZ,MAAM,2BAA2B,CAAC;AAcnC,MAAM,WAAW,iBAAiB;IAChC;;OAEG;IACH,MAAM,EAAE,QAAQ,CAAC;IAEjB;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAEhB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;;;GAKG;AACH,qBAAa,UAAW,YAAW,QAAQ;IACzC,OAAO,CAAC,MAAM,CAAW;IACzB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,QAAQ,CAAS;IAEzB,YAAY,OAAO,EAAE,iBAAiB,EAKrC;IAED,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,SAAS;IAWV,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CA6C1E;IAEK,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC,CAEf;IAED;;;;;;;;;;;;OAYG;IACG,eAAe,CACnB,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAGlB;IAED;;;;OAIG;YACW,MAAM;IA8Hd,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAYvC;IAED;;;;;;OAMG;IACI,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CA0GxE;IAEK,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CA+CxD;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAG3C;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAkE3C;IAED;;;;;;;OAOG;IACG,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAK3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAwD3D;CACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statewalker/webrun-files-s3",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "S3-backed FilesApi implementation for webrun-files",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"src"
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@statewalker/webrun-files": "^0.
|
|
37
|
+
"@statewalker/webrun-files": "^0.11.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@aws-sdk/client-s3": "^3.1032.0"
|
package/src/s3-files-api.ts
CHANGED
|
@@ -24,7 +24,12 @@ import type {
|
|
|
24
24
|
ListOptions,
|
|
25
25
|
ReadOptions,
|
|
26
26
|
} from "@statewalker/webrun-files";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
basename,
|
|
29
|
+
comparePaths,
|
|
30
|
+
normalizePath,
|
|
31
|
+
removeMovedSource,
|
|
32
|
+
} from "@statewalker/webrun-files";
|
|
28
33
|
|
|
29
34
|
/** Default part size for multipart uploads: 5MB (S3 minimum) */
|
|
30
35
|
const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
|
|
@@ -148,7 +153,42 @@ export class S3FilesApi implements FilesApi {
|
|
|
148
153
|
path: string,
|
|
149
154
|
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
150
155
|
): Promise<void> {
|
|
151
|
-
|
|
156
|
+
await this.upload(this.resolveKey(path), content, false);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A conditional write, `If-None-Match: *`, on the `PutObject` (or, above the multipart
|
|
161
|
+
* threshold, on the `CompleteMultipartUpload`) that makes the object visible: the
|
|
162
|
+
* service creates the key only if no object has it, and answers 412 otherwise. That
|
|
163
|
+
* excludes every client of the bucket. AWS S3 supports it since August 2024; an
|
|
164
|
+
* S3-compatible service that ignores the header gives no exclusion at all, so check
|
|
165
|
+
* yours before relying on this.
|
|
166
|
+
*
|
|
167
|
+
* Only the key itself is exclusive. A directory is a key prefix, so an existing one is
|
|
168
|
+
* turned away by a check before the upload, but one created concurrently with this
|
|
169
|
+
* call is not excluded. Nothing is visible until the content is complete; a failing
|
|
170
|
+
* source aborts the multipart upload and leaves no object.
|
|
171
|
+
*/
|
|
172
|
+
async createExclusive(
|
|
173
|
+
path: string,
|
|
174
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
175
|
+
): Promise<boolean> {
|
|
176
|
+
if (normalizePath(path) === "/" || (await this.stats(path))) return false;
|
|
177
|
+
return this.upload(this.resolveKey(path), content, true);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Upload `content` to `key`: one PutObject below the multipart threshold, else a
|
|
182
|
+
* multipart upload. With `exclusive`, the request that creates the object carries
|
|
183
|
+
* `If-None-Match: *`; returns false when the service refuses it because the key exists.
|
|
184
|
+
*/
|
|
185
|
+
private async upload(
|
|
186
|
+
key: string,
|
|
187
|
+
content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
|
|
188
|
+
exclusive: boolean,
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
const ifNoneMatch = exclusive ? "*" : undefined;
|
|
191
|
+
const sendLast = <T>(send: () => Promise<T>) => (exclusive ? sendConditional(send) : send());
|
|
152
192
|
|
|
153
193
|
// Buffer for accumulating small writes
|
|
154
194
|
let buffer: Uint8Array[] = [];
|
|
@@ -158,7 +198,8 @@ export class S3FilesApi implements FilesApi {
|
|
|
158
198
|
let partNumber = 1;
|
|
159
199
|
|
|
160
200
|
const flushPart = async (isLast: boolean) => {
|
|
161
|
-
|
|
201
|
+
// An exclusive create of empty content still has to create the object.
|
|
202
|
+
if (buffer.length === 0 && !(exclusive && isLast && !uploadId)) return;
|
|
162
203
|
|
|
163
204
|
const partLength = buffer.reduce((sum, c) => sum + c.length, 0);
|
|
164
205
|
const partBuffer = new Uint8Array(partLength);
|
|
@@ -177,8 +218,9 @@ export class S3FilesApi implements FilesApi {
|
|
|
177
218
|
Key: key,
|
|
178
219
|
Body: partBuffer,
|
|
179
220
|
ContentLength: partLength,
|
|
221
|
+
IfNoneMatch: ifNoneMatch,
|
|
180
222
|
});
|
|
181
|
-
await this.client.send(command);
|
|
223
|
+
await sendLast(() => this.client.send(command));
|
|
182
224
|
return;
|
|
183
225
|
}
|
|
184
226
|
|
|
@@ -240,9 +282,11 @@ export class S3FilesApi implements FilesApi {
|
|
|
240
282
|
MultipartUpload: {
|
|
241
283
|
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
|
|
242
284
|
},
|
|
285
|
+
IfNoneMatch: ifNoneMatch,
|
|
243
286
|
});
|
|
244
|
-
await this.client.send(completeCommand);
|
|
287
|
+
await sendLast(() => this.client.send(completeCommand));
|
|
245
288
|
}
|
|
289
|
+
return true;
|
|
246
290
|
} catch (error) {
|
|
247
291
|
// Abort multipart upload on error
|
|
248
292
|
if (uploadId) {
|
|
@@ -259,6 +303,7 @@ export class S3FilesApi implements FilesApi {
|
|
|
259
303
|
}
|
|
260
304
|
}
|
|
261
305
|
|
|
306
|
+
if (exclusive && isPreconditionFailed(error)) return false;
|
|
262
307
|
throw error;
|
|
263
308
|
}
|
|
264
309
|
}
|
|
@@ -514,10 +559,19 @@ export class S3FilesApi implements FilesApi {
|
|
|
514
559
|
return true;
|
|
515
560
|
}
|
|
516
561
|
|
|
562
|
+
/**
|
|
563
|
+
* S3 has no rename, so a move is a copy, then a delete, and is NOT atomic: while it
|
|
564
|
+
* runs, the object exists under both keys, and a directory (a key prefix) moves object
|
|
565
|
+
* by object. A failed copy throws and keeps the source. If the copy succeeds but the
|
|
566
|
+
* source cannot be deleted, the delete is retried once, then a `MoveIncompleteError`
|
|
567
|
+
* is thrown: the target holds the new content and the source still exists. False is
|
|
568
|
+
* never returned for a half-done move.
|
|
569
|
+
*/
|
|
517
570
|
async move(source: string, target: string): Promise<boolean> {
|
|
518
571
|
const copied = await this.copy(source, target);
|
|
519
572
|
if (!copied) return false;
|
|
520
|
-
|
|
573
|
+
await removeMovedSource(this, normalizePath(source), normalizePath(target));
|
|
574
|
+
return true;
|
|
521
575
|
}
|
|
522
576
|
|
|
523
577
|
async copy(source: string, target: string): Promise<boolean> {
|
|
@@ -599,3 +653,28 @@ function insertInOrder(entries: FileInfo[], entry: FileInfo): void {
|
|
|
599
653
|
while (i > 0 && comparePaths(entries[i - 1].path, entry.path) > 0) i--;
|
|
600
654
|
entries.splice(i, 0, entry);
|
|
601
655
|
}
|
|
656
|
+
|
|
657
|
+
/** The service refused a conditional write because the key exists (HTTP 412). */
|
|
658
|
+
function isPreconditionFailed(error: unknown): boolean {
|
|
659
|
+
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
660
|
+
return err?.name === "PreconditionFailed" || err?.$metadata?.httpStatusCode === 412;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Send a request that may carry `If-None-Match`, retrying a 409
|
|
665
|
+
* `ConditionalRequestConflict`: S3 answers that while another conditional write to the
|
|
666
|
+
* key is in flight, and asks the client to retry, after which one of them gets 412.
|
|
667
|
+
*/
|
|
668
|
+
async function sendConditional<T>(send: () => Promise<T>): Promise<T> {
|
|
669
|
+
for (let attempt = 0; ; attempt++) {
|
|
670
|
+
try {
|
|
671
|
+
return await send();
|
|
672
|
+
} catch (error) {
|
|
673
|
+
const err = error as { name?: string; $metadata?: { httpStatusCode?: number } };
|
|
674
|
+
const conflict =
|
|
675
|
+
err?.name === "ConditionalRequestConflict" || err?.$metadata?.httpStatusCode === 409;
|
|
676
|
+
if (!conflict || attempt >= 4) throw error;
|
|
677
|
+
await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|