@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 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.** They are many `CopyObject` and `DeleteObject`
128
- calls; a failure midway leaves some objects copied and the source partly removed.
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 (a fake client exercises listing order across pages).
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
 
@@ -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
- //#region src/s3-files-api.ts
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
- const key = this.resolveKey(path);
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
- return this.remove(source);
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
- //#endregion
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
- //#region src/s3-files-api.ts
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
- const key = this.resolveKey(path);
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
- return this.remove(source);
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
- //#endregion
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 };
@@ -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;AASnC,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,CAkHf;IAEK,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;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAI3D;IAEK,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAwD3D;CACF"}
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.10.4",
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.10.4"
37
+ "@statewalker/webrun-files": "^0.11.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@aws-sdk/client-s3": "^3.1032.0"
@@ -24,7 +24,12 @@ import type {
24
24
  ListOptions,
25
25
  ReadOptions,
26
26
  } from "@statewalker/webrun-files";
27
- import { basename, comparePaths, normalizePath } from "@statewalker/webrun-files";
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
- const key = this.resolveKey(path);
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
- if (buffer.length === 0) return;
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
- return this.remove(source);
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
+ }