@statewalker/webrun-files-s3 0.7.0 → 0.10.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
@@ -50,7 +50,7 @@ console.log(content); // "Hello, S3!"
50
50
 
51
51
  // List directory contents
52
52
  for await (const entry of files.list('/docs')) {
53
- console.log(entry.name, entry.kind, entry.size);
53
+ console.log(entry.name, entry.kind, entry.kind === 'file' ? entry.size : '');
54
54
  }
55
55
  ```
56
56
 
@@ -142,6 +142,28 @@ S3 doesn't have real directories, but this implementation simulates them using:
142
142
  - **ListObjectsV2** with `Delimiter="/"` to get "subdirectories" via `CommonPrefixes`
143
143
  - Files are returned from `Contents`
144
144
 
145
+ With `{ recursive: true }` the delimiter is omitted and only file entries are yielded: directories,
146
+ including empty ones created by `mkdir()`, do not appear in a recursive listing.
147
+
148
+ Entries come out in path order (code-point order, as every `FilesApi` lists), and `{ after }` is
149
+ sent to S3 as `StartAfter`, so resuming a listing does not re-list what came before. Keys list in
150
+ UTF-8 byte order, which is already that order for files; but a directory `a` arrives as the common
151
+ prefix `a/` *after* keys such as `a-x` and `a.txt` that must follow it. A non-recursive listing
152
+ therefore holds back the few entries a directory still to come could precede, across page
153
+ boundaries, and releases them in order. When a key `a` and keys under `a/` both exist, the path
154
+ `/a` is listed once, as the file — the same answer `stats()` gives.
155
+
156
+ Because `FileStats` is a discriminated union, this implementation has to commit
157
+ to two things a store without real directories could otherwise leave vague:
158
+
159
+ - A **common prefix is the directory variant** - `{ kind: "directory" }` and
160
+ nothing more. There is no size or modification time to report for something
161
+ that exists only as an artefact of key naming.
162
+ - **Any key ending in `/` is a directory, not a file** — such as the zero-byte
163
+ marker `mkdir()` writes so an empty directory is visible — and it is skipped
164
+ when reading `Contents`. Every other key is a file, and a genuinely empty
165
+ object is the file variant with `size: 0`.
166
+
145
167
  ```typescript
146
168
  // List /docs with prefix "my-app"
147
169
  // S3 request: ListObjectsV2(Prefix="my-app/docs/", Delimiter="/")
@@ -210,31 +232,26 @@ This implementation works with any S3-compatible storage:
210
232
 
211
233
  ## Testing
212
234
 
213
- Tests use [testcontainers](https://github.com/testcontainers/testcontainers-node) with MinIO:
235
+ The package has integration tests only. They need Docker and run against a real S3-compatible
236
+ server, [RustFS](https://github.com/rustfs/rustfs), started through
237
+ [testcontainers](https://github.com/testcontainers/testcontainers-node) as a `GenericContainer` of
238
+ `rustfs/rustfs:latest` (override the image with `RUSTFS_IMAGE`). They run the shared
239
+ `createFilesApiTests` and `createBigFilesApiTests` suites from `@statewalker/webrun-files-tests`.
214
240
 
215
- ```typescript
216
- import { MinioContainer } from '@testcontainers/minio';
217
- import { S3Client, CreateBucketCommand } from '@aws-sdk/client-s3';
218
- import { S3FilesApi } from '@statewalker/webrun-files-s3';
241
+ ```bash
242
+ pnpm test # unit profile: excludes *.integration.test.ts, so no Docker needed (runs nothing today)
243
+ pnpm test:integration # Docker: RustFS container, all suites including the 256 MiB big-file suite
244
+ ```
219
245
 
220
- const minioContainer = await new MinioContainer().start();
246
+ The client the tests build is the one to use against any S3-compatible server on a custom endpoint:
221
247
 
248
+ ```typescript
222
249
  const s3Client = new S3Client({
223
- endpoint: minioContainer.getConnectionUrl(),
250
+ endpoint: `http://${container.getHost()}:${container.getMappedPort(9000)}`,
224
251
  region: 'us-east-1',
225
- credentials: {
226
- accessKeyId: minioContainer.getUsername(),
227
- secretAccessKey: minioContainer.getPassword(),
228
- },
252
+ credentials: { accessKeyId: 'rustfsadmin', secretAccessKey: 'rustfsadmin' },
229
253
  forcePathStyle: true,
230
254
  });
231
-
232
- await s3Client.send(new CreateBucketCommand({ Bucket: 'test-bucket' }));
233
-
234
- const files = new S3FilesApi({
235
- client: s3Client,
236
- bucket: 'test-bucket',
237
- });
238
255
  ```
239
256
 
240
257
  ## License
@@ -1,11 +1,11 @@
1
- let __aws_sdk_client_s3 = require("@aws-sdk/client-s3");
2
- let __statewalker_webrun_files = require("@statewalker/webrun-files");
3
-
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _aws_sdk_client_s3 = require("@aws-sdk/client-s3");
3
+ let _statewalker_webrun_files = require("@statewalker/webrun-files");
4
4
  //#region src/s3-files-api.ts
5
5
  /** Default part size for multipart uploads: 5MB (S3 minimum) */
6
- const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
6
+ const DEFAULT_PART_SIZE = 5242880;
7
7
  /** Threshold for using multipart upload: 5MB */
8
- const MULTIPART_THRESHOLD = 5 * 1024 * 1024;
8
+ const MULTIPART_THRESHOLD = 5242880;
9
9
  /**
10
10
  * S3 filesystem implementation of FilesApi.
11
11
  *
@@ -24,7 +24,7 @@ var S3FilesApi = class {
24
24
  this.partSize = options.multipartPartSize ?? DEFAULT_PART_SIZE;
25
25
  }
26
26
  resolveKey(path) {
27
- const normalized = (0, __statewalker_webrun_files.normalizePath)(path);
27
+ const normalized = (0, _statewalker_webrun_files.normalizePath)(path);
28
28
  const relativePath = normalized.startsWith("/") ? normalized.substring(1) : normalized;
29
29
  if (this.prefix) return relativePath ? `${this.prefix}/${relativePath}` : this.prefix;
30
30
  return relativePath;
@@ -45,7 +45,7 @@ var S3FilesApi = class {
45
45
  if (length === 0) return;
46
46
  let range;
47
47
  if (start > 0 || length !== void 0) range = `bytes=${start}-${length !== void 0 ? start + length - 1 : ""}`;
48
- const command = new __aws_sdk_client_s3.GetObjectCommand({
48
+ const command = new _aws_sdk_client_s3.GetObjectCommand({
49
49
  Bucket: this.bucket,
50
50
  Key: key,
51
51
  Range: range
@@ -80,7 +80,7 @@ var S3FilesApi = class {
80
80
  buffer = [];
81
81
  bufferSize = 0;
82
82
  if (!uploadId && isLast && partLength < MULTIPART_THRESHOLD) {
83
- const command = new __aws_sdk_client_s3.PutObjectCommand({
83
+ const command = new _aws_sdk_client_s3.PutObjectCommand({
84
84
  Bucket: this.bucket,
85
85
  Key: key,
86
86
  Body: partBuffer,
@@ -90,7 +90,7 @@ var S3FilesApi = class {
90
90
  return;
91
91
  }
92
92
  if (!uploadId) {
93
- const createCommand = new __aws_sdk_client_s3.CreateMultipartUploadCommand({
93
+ const createCommand = new _aws_sdk_client_s3.CreateMultipartUploadCommand({
94
94
  Bucket: this.bucket,
95
95
  Key: key
96
96
  });
@@ -98,7 +98,7 @@ var S3FilesApi = class {
98
98
  if (!UploadId) throw new Error("Failed to create multipart upload");
99
99
  uploadId = UploadId;
100
100
  }
101
- const uploadCommand = new __aws_sdk_client_s3.UploadPartCommand({
101
+ const uploadCommand = new _aws_sdk_client_s3.UploadPartCommand({
102
102
  Bucket: this.bucket,
103
103
  Key: key,
104
104
  UploadId: uploadId,
@@ -121,7 +121,7 @@ var S3FilesApi = class {
121
121
  }
122
122
  await flushPart(true);
123
123
  if (uploadId) {
124
- const completeCommand = new __aws_sdk_client_s3.CompleteMultipartUploadCommand({
124
+ const completeCommand = new _aws_sdk_client_s3.CompleteMultipartUploadCommand({
125
125
  Bucket: this.bucket,
126
126
  Key: key,
127
127
  UploadId: uploadId,
@@ -131,7 +131,7 @@ var S3FilesApi = class {
131
131
  }
132
132
  } catch (error) {
133
133
  if (uploadId) {
134
- const abortCommand = new __aws_sdk_client_s3.AbortMultipartUploadCommand({
134
+ const abortCommand = new _aws_sdk_client_s3.AbortMultipartUploadCommand({
135
135
  Bucket: this.bucket,
136
136
  Key: key,
137
137
  UploadId: uploadId
@@ -146,65 +146,108 @@ var S3FilesApi = class {
146
146
  async mkdir(path) {
147
147
  const key = this.resolveKey(path);
148
148
  const dirKey = key.endsWith("/") ? key : `${key}/`;
149
- const command = new __aws_sdk_client_s3.PutObjectCommand({
149
+ const command = new _aws_sdk_client_s3.PutObjectCommand({
150
150
  Bucket: this.bucket,
151
151
  Key: dirKey,
152
- Body: new Uint8Array(0),
152
+ Body: /* @__PURE__ */ new Uint8Array(0),
153
153
  ContentLength: 0
154
154
  });
155
155
  await this.client.send(command);
156
156
  }
157
+ /**
158
+ * Keys list in UTF-8 byte order, which is `comparePaths` order for files.
159
+ * A recursive listing is therefore already ordered. A non-recursive one is
160
+ * not quite: the directory `a` arrives as the common prefix `a/`, after keys
161
+ * such as `a-x` and `a.txt` that must follow it, so entries pass through a
162
+ * small reorder buffer (see {@link releaseBound}).
163
+ */
157
164
  async *list(path, options) {
158
165
  const recursive = options?.recursive ?? false;
166
+ const after = options?.after;
167
+ const dirPath = (0, _statewalker_webrun_files.normalizePath)(path);
159
168
  const prefix = this.resolveKey(path);
160
169
  const normalizedPrefix = prefix ? `${prefix}/` : this.prefix ? `${this.prefix}/` : "";
170
+ const dirPrefix = dirPath === "/" ? "" : dirPath;
171
+ let startAfter;
172
+ if (after !== void 0) {
173
+ if (after.startsWith(`${dirPrefix}/`)) startAfter = normalizedPrefix + after.slice(dirPrefix.length + 1);
174
+ else if ((0, _statewalker_webrun_files.comparePaths)(after, `${dirPrefix}/`) >= 0) return;
175
+ }
176
+ const pending = [];
177
+ let released;
178
+ const release = (entry) => {
179
+ if (entry.path === released) return false;
180
+ released = entry.path;
181
+ return after === void 0 || (0, _statewalker_webrun_files.comparePaths)(entry.path, after) > 0;
182
+ };
161
183
  let continuationToken;
162
184
  do {
163
- const command = new __aws_sdk_client_s3.ListObjectsV2Command({
185
+ const command = new _aws_sdk_client_s3.ListObjectsV2Command({
164
186
  Bucket: this.bucket,
165
187
  Prefix: normalizedPrefix,
166
188
  Delimiter: recursive ? void 0 : "/",
167
189
  ContinuationToken: continuationToken,
190
+ StartAfter: continuationToken ? void 0 : startAfter,
168
191
  MaxKeys: 1e3
169
192
  });
170
193
  const response = await this.client.send(command);
171
- if (!recursive && response.CommonPrefixes) for (const cpPrefix of response.CommonPrefixes) {
194
+ const page = [];
195
+ for (const cpPrefix of recursive ? [] : response.CommonPrefixes ?? []) {
172
196
  if (!cpPrefix.Prefix) continue;
173
197
  const dirKey = cpPrefix.Prefix.endsWith("/") ? cpPrefix.Prefix.slice(0, -1) : cpPrefix.Prefix;
174
- const dirPath = this.keyToPath(dirKey);
175
- yield {
176
- kind: "directory",
177
- name: (0, __statewalker_webrun_files.basename)(dirPath),
178
- path: dirPath,
179
- lastModified: 0
180
- };
198
+ const childPath = this.keyToPath(dirKey);
199
+ page.push({
200
+ key: cpPrefix.Prefix,
201
+ entry: {
202
+ kind: "directory",
203
+ name: (0, _statewalker_webrun_files.basename)(childPath),
204
+ path: childPath
205
+ }
206
+ });
181
207
  }
182
- if (response.Contents) for (const obj of response.Contents) {
208
+ for (const obj of response.Contents ?? []) {
183
209
  if (!obj.Key) continue;
184
210
  if (obj.Key === normalizedPrefix) continue;
185
211
  if (`${obj.Key}/` === normalizedPrefix) continue;
186
- if (obj.Key.endsWith("/")) continue;
212
+ if (obj.Key.endsWith("/")) {
213
+ page.push({ key: obj.Key });
214
+ continue;
215
+ }
187
216
  const filePath = this.keyToPath(obj.Key);
188
- yield {
189
- kind: "file",
190
- name: (0, __statewalker_webrun_files.basename)(filePath),
191
- path: filePath,
192
- size: obj.Size ?? 0,
193
- lastModified: obj.LastModified?.getTime() ?? 0
194
- };
217
+ page.push({
218
+ key: obj.Key,
219
+ entry: {
220
+ kind: "file",
221
+ name: (0, _statewalker_webrun_files.basename)(filePath),
222
+ path: filePath,
223
+ size: obj.Size ?? 0,
224
+ lastModified: obj.LastModified?.getTime() ?? 0
225
+ }
226
+ });
227
+ }
228
+ page.sort((x, y) => (0, _statewalker_webrun_files.comparePaths)(x.key, y.key));
229
+ for (const { key, entry } of page) {
230
+ if (recursive) {
231
+ if (entry && release(entry)) yield entry;
232
+ continue;
233
+ }
234
+ if (entry) insertInOrder(pending, entry);
235
+ const bound = releaseBound(this.keyToPath(key));
236
+ while (pending.length > 0 && (0, _statewalker_webrun_files.comparePaths)(pending[0].path, bound) < 0) {
237
+ const next = pending.shift();
238
+ if (release(next)) yield next;
239
+ }
195
240
  }
196
241
  continuationToken = response.NextContinuationToken;
197
242
  } while (continuationToken);
243
+ for (const entry of pending) if (release(entry)) yield entry;
198
244
  }
199
245
  async stats(path) {
200
- const normalized = (0, __statewalker_webrun_files.normalizePath)(path);
246
+ const normalized = (0, _statewalker_webrun_files.normalizePath)(path);
201
247
  const key = this.resolveKey(path);
202
- if (normalized === "/") return {
203
- kind: "directory",
204
- lastModified: 0
205
- };
248
+ if (normalized === "/") return { kind: "directory" };
206
249
  try {
207
- const command = new __aws_sdk_client_s3.HeadObjectCommand({
250
+ const command = new _aws_sdk_client_s3.HeadObjectCommand({
208
251
  Bucket: this.bucket,
209
252
  Key: key
210
253
  });
@@ -219,16 +262,13 @@ var S3FilesApi = class {
219
262
  if (err.name !== "NotFound" && err.$metadata?.httpStatusCode !== 404) throw error;
220
263
  }
221
264
  const prefix = key ? `${key}/` : "";
222
- const listCommand = new __aws_sdk_client_s3.ListObjectsV2Command({
265
+ const listCommand = new _aws_sdk_client_s3.ListObjectsV2Command({
223
266
  Bucket: this.bucket,
224
267
  Prefix: prefix,
225
268
  MaxKeys: 1
226
269
  });
227
270
  const listResponse = await this.client.send(listCommand);
228
- if (listResponse.Contents && listResponse.Contents.length > 0 || listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0) return {
229
- kind: "directory",
230
- lastModified: 0
231
- };
271
+ if (listResponse.Contents && listResponse.Contents.length > 0 || listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0) return { kind: "directory" };
232
272
  }
233
273
  async exists(path) {
234
274
  return await this.stats(path) !== void 0;
@@ -236,12 +276,12 @@ var S3FilesApi = class {
236
276
  async remove(path) {
237
277
  const key = this.resolveKey(path);
238
278
  try {
239
- const headCommand = new __aws_sdk_client_s3.HeadObjectCommand({
279
+ const headCommand = new _aws_sdk_client_s3.HeadObjectCommand({
240
280
  Bucket: this.bucket,
241
281
  Key: key
242
282
  });
243
283
  await this.client.send(headCommand);
244
- const deleteCommand = new __aws_sdk_client_s3.DeleteObjectCommand({
284
+ const deleteCommand = new _aws_sdk_client_s3.DeleteObjectCommand({
245
285
  Bucket: this.bucket,
246
286
  Key: key
247
287
  });
@@ -255,7 +295,7 @@ var S3FilesApi = class {
255
295
  const keysToDelete = [];
256
296
  let continuationToken;
257
297
  do {
258
- const listCommand = new __aws_sdk_client_s3.ListObjectsV2Command({
298
+ const listCommand = new _aws_sdk_client_s3.ListObjectsV2Command({
259
299
  Bucket: this.bucket,
260
300
  Prefix: prefix,
261
301
  ContinuationToken: continuationToken
@@ -268,7 +308,7 @@ var S3FilesApi = class {
268
308
  } while (continuationToken);
269
309
  if (keysToDelete.length === 0) return false;
270
310
  for (const keyToDelete of keysToDelete) {
271
- const deleteCommand = new __aws_sdk_client_s3.DeleteObjectCommand({
311
+ const deleteCommand = new _aws_sdk_client_s3.DeleteObjectCommand({
272
312
  Bucket: this.bucket,
273
313
  Key: keyToDelete
274
314
  });
@@ -286,7 +326,7 @@ var S3FilesApi = class {
286
326
  const sourceStats = await this.stats(source);
287
327
  if (!sourceStats) return false;
288
328
  if (sourceStats.kind === "file") {
289
- const command = new __aws_sdk_client_s3.CopyObjectCommand({
329
+ const command = new _aws_sdk_client_s3.CopyObjectCommand({
290
330
  Bucket: this.bucket,
291
331
  Key: targetKey,
292
332
  CopySource: encodeURIComponent(`${this.bucket}/${sourceKey}`)
@@ -299,7 +339,7 @@ var S3FilesApi = class {
299
339
  let continuationToken;
300
340
  let copied = false;
301
341
  do {
302
- const listCommand = new __aws_sdk_client_s3.ListObjectsV2Command({
342
+ const listCommand = new _aws_sdk_client_s3.ListObjectsV2Command({
303
343
  Bucket: this.bucket,
304
344
  Prefix: sourcePrefix,
305
345
  ContinuationToken: continuationToken
@@ -308,7 +348,7 @@ var S3FilesApi = class {
308
348
  if (response.Contents) for (const obj of response.Contents) {
309
349
  if (!obj.Key) continue;
310
350
  const newKey = targetPrefix + obj.Key.substring(sourcePrefix.length);
311
- const copyCommand = new __aws_sdk_client_s3.CopyObjectCommand({
351
+ const copyCommand = new _aws_sdk_client_s3.CopyObjectCommand({
312
352
  Bucket: this.bucket,
313
353
  Key: newKey,
314
354
  CopySource: encodeURIComponent(`${this.bucket}/${obj.Key}`)
@@ -321,6 +361,21 @@ var S3FilesApi = class {
321
361
  return copied;
322
362
  }
323
363
  };
324
-
364
+ /**
365
+ * Once a delimited listing has reached key position `position` (as a path), a
366
+ * directory still to come — which arrives as `D + "/"` at or after that
367
+ * position — has a path of at least this bound: the shortest prefix of
368
+ * `position` followed by a character that sorts before `/`, or `position`
369
+ * itself if there is none. Buffered entries below the bound are final.
370
+ */
371
+ function releaseBound(position) {
372
+ for (let i = 0; i < position.length; i++) if (position.charCodeAt(i) < 47) return position.slice(0, i);
373
+ return position;
374
+ }
375
+ function insertInOrder(entries, entry) {
376
+ let i = entries.length;
377
+ while (i > 0 && (0, _statewalker_webrun_files.comparePaths)(entries[i - 1].path, entry.path) > 0) i--;
378
+ entries.splice(i, 0, entry);
379
+ }
325
380
  //#endregion
326
- exports.S3FilesApi = S3FilesApi;
381
+ exports.S3FilesApi = S3FilesApi;
package/dist/esm/index.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CopyObjectCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, UploadPartCommand } from "@aws-sdk/client-s3";
2
- import { basename, normalizePath } from "@statewalker/webrun-files";
3
-
2
+ import { basename, comparePaths, normalizePath } from "@statewalker/webrun-files";
4
3
  //#region src/s3-files-api.ts
5
4
  /** Default part size for multipart uploads: 5MB (S3 minimum) */
6
- const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
5
+ const DEFAULT_PART_SIZE = 5242880;
7
6
  /** Threshold for using multipart upload: 5MB */
8
- const MULTIPART_THRESHOLD = 5 * 1024 * 1024;
7
+ const MULTIPART_THRESHOLD = 5242880;
9
8
  /**
10
9
  * S3 filesystem implementation of FilesApi.
11
10
  *
@@ -149,15 +148,37 @@ var S3FilesApi = class {
149
148
  const command = new PutObjectCommand({
150
149
  Bucket: this.bucket,
151
150
  Key: dirKey,
152
- Body: new Uint8Array(0),
151
+ Body: /* @__PURE__ */ new Uint8Array(0),
153
152
  ContentLength: 0
154
153
  });
155
154
  await this.client.send(command);
156
155
  }
156
+ /**
157
+ * Keys list in UTF-8 byte order, which is `comparePaths` order for files.
158
+ * A recursive listing is therefore already ordered. A non-recursive one is
159
+ * not quite: the directory `a` arrives as the common prefix `a/`, after keys
160
+ * such as `a-x` and `a.txt` that must follow it, so entries pass through a
161
+ * small reorder buffer (see {@link releaseBound}).
162
+ */
157
163
  async *list(path, options) {
158
164
  const recursive = options?.recursive ?? false;
165
+ const after = options?.after;
166
+ const dirPath = normalizePath(path);
159
167
  const prefix = this.resolveKey(path);
160
168
  const normalizedPrefix = prefix ? `${prefix}/` : this.prefix ? `${this.prefix}/` : "";
169
+ const dirPrefix = dirPath === "/" ? "" : dirPath;
170
+ let startAfter;
171
+ if (after !== void 0) {
172
+ if (after.startsWith(`${dirPrefix}/`)) startAfter = normalizedPrefix + after.slice(dirPrefix.length + 1);
173
+ else if (comparePaths(after, `${dirPrefix}/`) >= 0) return;
174
+ }
175
+ const pending = [];
176
+ let released;
177
+ const release = (entry) => {
178
+ if (entry.path === released) return false;
179
+ released = entry.path;
180
+ return after === void 0 || comparePaths(entry.path, after) > 0;
181
+ };
161
182
  let continuationToken;
162
183
  do {
163
184
  const command = new ListObjectsV2Command({
@@ -165,44 +186,65 @@ var S3FilesApi = class {
165
186
  Prefix: normalizedPrefix,
166
187
  Delimiter: recursive ? void 0 : "/",
167
188
  ContinuationToken: continuationToken,
189
+ StartAfter: continuationToken ? void 0 : startAfter,
168
190
  MaxKeys: 1e3
169
191
  });
170
192
  const response = await this.client.send(command);
171
- if (!recursive && response.CommonPrefixes) for (const cpPrefix of response.CommonPrefixes) {
193
+ const page = [];
194
+ for (const cpPrefix of recursive ? [] : response.CommonPrefixes ?? []) {
172
195
  if (!cpPrefix.Prefix) continue;
173
196
  const dirKey = cpPrefix.Prefix.endsWith("/") ? cpPrefix.Prefix.slice(0, -1) : cpPrefix.Prefix;
174
- const dirPath = this.keyToPath(dirKey);
175
- yield {
176
- kind: "directory",
177
- name: basename(dirPath),
178
- path: dirPath,
179
- lastModified: 0
180
- };
197
+ const childPath = this.keyToPath(dirKey);
198
+ page.push({
199
+ key: cpPrefix.Prefix,
200
+ entry: {
201
+ kind: "directory",
202
+ name: basename(childPath),
203
+ path: childPath
204
+ }
205
+ });
181
206
  }
182
- if (response.Contents) for (const obj of response.Contents) {
207
+ for (const obj of response.Contents ?? []) {
183
208
  if (!obj.Key) continue;
184
209
  if (obj.Key === normalizedPrefix) continue;
185
210
  if (`${obj.Key}/` === normalizedPrefix) continue;
186
- if (obj.Key.endsWith("/")) continue;
211
+ if (obj.Key.endsWith("/")) {
212
+ page.push({ key: obj.Key });
213
+ continue;
214
+ }
187
215
  const filePath = this.keyToPath(obj.Key);
188
- yield {
189
- kind: "file",
190
- name: basename(filePath),
191
- path: filePath,
192
- size: obj.Size ?? 0,
193
- lastModified: obj.LastModified?.getTime() ?? 0
194
- };
216
+ page.push({
217
+ key: obj.Key,
218
+ entry: {
219
+ kind: "file",
220
+ name: basename(filePath),
221
+ path: filePath,
222
+ size: obj.Size ?? 0,
223
+ lastModified: obj.LastModified?.getTime() ?? 0
224
+ }
225
+ });
226
+ }
227
+ page.sort((x, y) => comparePaths(x.key, y.key));
228
+ for (const { key, entry } of page) {
229
+ if (recursive) {
230
+ if (entry && release(entry)) yield entry;
231
+ continue;
232
+ }
233
+ if (entry) insertInOrder(pending, entry);
234
+ const bound = releaseBound(this.keyToPath(key));
235
+ while (pending.length > 0 && comparePaths(pending[0].path, bound) < 0) {
236
+ const next = pending.shift();
237
+ if (release(next)) yield next;
238
+ }
195
239
  }
196
240
  continuationToken = response.NextContinuationToken;
197
241
  } while (continuationToken);
242
+ for (const entry of pending) if (release(entry)) yield entry;
198
243
  }
199
244
  async stats(path) {
200
245
  const normalized = normalizePath(path);
201
246
  const key = this.resolveKey(path);
202
- if (normalized === "/") return {
203
- kind: "directory",
204
- lastModified: 0
205
- };
247
+ if (normalized === "/") return { kind: "directory" };
206
248
  try {
207
249
  const command = new HeadObjectCommand({
208
250
  Bucket: this.bucket,
@@ -225,10 +267,7 @@ var S3FilesApi = class {
225
267
  MaxKeys: 1
226
268
  });
227
269
  const listResponse = await this.client.send(listCommand);
228
- if (listResponse.Contents && listResponse.Contents.length > 0 || listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0) return {
229
- kind: "directory",
230
- lastModified: 0
231
- };
270
+ if (listResponse.Contents && listResponse.Contents.length > 0 || listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0) return { kind: "directory" };
232
271
  }
233
272
  async exists(path) {
234
273
  return await this.stats(path) !== void 0;
@@ -321,6 +360,21 @@ var S3FilesApi = class {
321
360
  return copied;
322
361
  }
323
362
  };
324
-
363
+ /**
364
+ * Once a delimited listing has reached key position `position` (as a path), a
365
+ * directory still to come — which arrives as `D + "/"` at or after that
366
+ * position — has a path of at least this bound: the shortest prefix of
367
+ * `position` followed by a character that sorts before `/`, or `position`
368
+ * itself if there is none. Buffered entries below the bound are final.
369
+ */
370
+ function releaseBound(position) {
371
+ for (let i = 0; i < position.length; i++) if (position.charCodeAt(i) < 47) return position.slice(0, i);
372
+ return position;
373
+ }
374
+ function insertInOrder(entries, entry) {
375
+ let i = entries.length;
376
+ while (i > 0 && comparePaths(entries[i - 1].path, entry.path) > 0) i--;
377
+ entries.splice(i, 0, entry);
378
+ }
325
379
  //#endregion
326
- export { S3FilesApi };
380
+ export { S3FilesApi };
@@ -43,6 +43,13 @@ export declare class S3FilesApi implements FilesApi {
43
43
  read(path: string, options?: ReadOptions): AsyncIterable<Uint8Array>;
44
44
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
45
45
  mkdir(path: string): Promise<void>;
46
+ /**
47
+ * Keys list in UTF-8 byte order, which is `comparePaths` order for files.
48
+ * A recursive listing is therefore already ordered. A non-recursive one is
49
+ * not quite: the directory `a` arrives as the common prefix `a/`, after keys
50
+ * such as `a-x` and `a.txt` that must follow it, so entries pass through a
51
+ * small reorder buffer (see {@link releaseBound}).
52
+ */
46
53
  list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
47
54
  stats(path: string): Promise<FileStats | undefined>;
48
55
  exists(path: string): Promise<boolean>;
@@ -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;gBAEb,OAAO,EAAE,iBAAiB;IAOtC,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,SAAS;IAWV,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC;IA+CrE,KAAK,CACT,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,IAAI,CAAC;IAoHV,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcjC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC;IA4DnE,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAuDnD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKtC,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAoEtC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAMtD,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAyD7D"}
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files-s3",
3
- "version": "0.7.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "S3-backed FilesApi implementation for webrun-files",
@@ -29,21 +29,20 @@
29
29
  "src"
30
30
  ],
31
31
  "dependencies": {
32
- "@statewalker/webrun-files": "0.7.0"
32
+ "@statewalker/webrun-files": "0.10.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@aws-sdk/client-s3": "^3.0.0"
35
+ "@aws-sdk/client-s3": "^3.1032.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@aws-sdk/client-s3": "^3.0.0",
39
- "@testcontainers/minio": "^10.0.0",
40
- "@types/node": "^24.10.1",
41
- "rimraf": "^6.1.2",
42
- "rolldown": "^1.0.0-beta.53",
43
- "testcontainers": "^10.0.0",
44
- "typescript": "^5.6.0",
45
- "vitest": "^4.0.12",
46
- "@statewalker/webrun-files-tests": "1.0.0"
38
+ "@aws-sdk/client-s3": "^3.1110.0",
39
+ "@types/node": "^26.2.0",
40
+ "rimraf": "^6.1.3",
41
+ "rolldown": "^1.2.4",
42
+ "testcontainers": "^12.1.0",
43
+ "typescript": "^7.0.2",
44
+ "vitest": "^4.1.10",
45
+ "@statewalker/webrun-files-tests": "3.0.0"
47
46
  },
48
47
  "sideEffects": false,
49
48
  "publishConfig": {
@@ -51,7 +50,8 @@
51
50
  },
52
51
  "scripts": {
53
52
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
54
- "test": "vitest run",
55
- "lint": "biome lint src tests"
53
+ "lint": "biome lint src tests",
54
+ "test": "vitest run --passWithNoTests",
55
+ "test:integration": "vitest run --config vitest.integration.config.ts"
56
56
  }
57
57
  }
@@ -24,7 +24,7 @@ import type {
24
24
  ListOptions,
25
25
  ReadOptions,
26
26
  } from "@statewalker/webrun-files";
27
- import { basename, normalizePath } from "@statewalker/webrun-files";
27
+ import { basename, comparePaths, normalizePath } from "@statewalker/webrun-files";
28
28
 
29
29
  /** Default part size for multipart uploads: 5MB (S3 minimum) */
30
30
  const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
@@ -277,11 +277,42 @@ export class S3FilesApi implements FilesApi {
277
277
  await this.client.send(command);
278
278
  }
279
279
 
280
+ /**
281
+ * Keys list in UTF-8 byte order, which is `comparePaths` order for files.
282
+ * A recursive listing is therefore already ordered. A non-recursive one is
283
+ * not quite: the directory `a` arrives as the common prefix `a/`, after keys
284
+ * such as `a-x` and `a.txt` that must follow it, so entries pass through a
285
+ * small reorder buffer (see {@link releaseBound}).
286
+ */
280
287
  async *list(path: string, options?: ListOptions): AsyncIterable<FileInfo> {
281
288
  const recursive = options?.recursive ?? false;
289
+ const after = options?.after;
290
+ const dirPath = normalizePath(path);
282
291
  const prefix = this.resolveKey(path);
283
292
  const normalizedPrefix = prefix ? `${prefix}/` : this.prefix ? `${this.prefix}/` : "";
284
293
 
294
+ // `after` as a key, by raw prefix replacement: normalising the path could
295
+ // move the cursor past entries that sort after it.
296
+ const dirPrefix = dirPath === "/" ? "" : dirPath;
297
+ let startAfter: string | undefined;
298
+ if (after !== undefined) {
299
+ if (after.startsWith(`${dirPrefix}/`)) {
300
+ startAfter = normalizedPrefix + after.slice(dirPrefix.length + 1);
301
+ } else if (comparePaths(after, `${dirPrefix}/`) >= 0) {
302
+ return; // the whole directory lies at or before the cursor
303
+ }
304
+ }
305
+
306
+ const pending: FileInfo[] = [];
307
+ // A key `a` and keys under `a/` make a file and a directory with the same
308
+ // path. A listing is strictly increasing, so the first released — the file,
309
+ // as `stats` also reports — is kept.
310
+ let released: string | undefined;
311
+ const release = (entry: FileInfo): boolean => {
312
+ if (entry.path === released) return false;
313
+ released = entry.path;
314
+ return after === undefined || comparePaths(entry.path, after) > 0;
315
+ };
285
316
  let continuationToken: string | undefined;
286
317
 
287
318
  do {
@@ -290,51 +321,75 @@ export class S3FilesApi implements FilesApi {
290
321
  Prefix: normalizedPrefix,
291
322
  Delimiter: recursive ? undefined : "/",
292
323
  ContinuationToken: continuationToken,
324
+ StartAfter: continuationToken ? undefined : startAfter,
293
325
  MaxKeys: 1000,
294
326
  });
295
327
 
296
328
  const response = await this.client.send(command);
297
329
 
298
- if (!recursive && response.CommonPrefixes) {
299
- for (const cpPrefix of response.CommonPrefixes) {
300
- if (!cpPrefix.Prefix) continue;
301
-
302
- const dirKey = cpPrefix.Prefix.endsWith("/")
303
- ? cpPrefix.Prefix.slice(0, -1)
304
- : cpPrefix.Prefix;
305
-
306
- const dirPath = this.keyToPath(dirKey);
307
-
308
- yield {
309
- kind: "directory",
310
- name: basename(dirPath),
311
- path: dirPath,
312
- lastModified: 0,
313
- };
314
- }
330
+ // Common prefixes and contents each come sorted; interleave them by key.
331
+ const page: { key: string; entry?: FileInfo }[] = [];
332
+ for (const cpPrefix of recursive ? [] : (response.CommonPrefixes ?? [])) {
333
+ if (!cpPrefix.Prefix) continue;
334
+ const dirKey = cpPrefix.Prefix.endsWith("/")
335
+ ? cpPrefix.Prefix.slice(0, -1)
336
+ : cpPrefix.Prefix;
337
+ const childPath = this.keyToPath(dirKey);
338
+ // A common prefix IS the directory variant: it exists only as an
339
+ // artefact of key naming, so there is nothing to report about it
340
+ // beyond its being a directory.
341
+ page.push({
342
+ key: cpPrefix.Prefix,
343
+ entry: { kind: "directory", name: basename(childPath), path: childPath },
344
+ });
315
345
  }
316
-
317
- if (response.Contents) {
318
- for (const obj of response.Contents) {
319
- if (!obj.Key) continue;
320
- if (obj.Key === normalizedPrefix) continue;
321
- if (`${obj.Key}/` === normalizedPrefix) continue;
322
- if (obj.Key.endsWith("/")) continue;
323
-
324
- const filePath = this.keyToPath(obj.Key);
325
-
326
- yield {
346
+ for (const obj of response.Contents ?? []) {
347
+ if (!obj.Key) continue;
348
+ if (obj.Key === normalizedPrefix) continue;
349
+ if (`${obj.Key}/` === normalizedPrefix) continue;
350
+ // A key ending in "/" is the zero-byte marker `mkdir` writes to make
351
+ // an empty directory visible. It is NOT a file with `size: 0` — it
352
+ // is how this store spells a directory, and it surfaces through
353
+ // `CommonPrefixes` above. Every other key is a file, and a genuinely
354
+ // empty object is the file variant with `size: 0`. It still moves
355
+ // the listing's position forward.
356
+ if (obj.Key.endsWith("/")) {
357
+ page.push({ key: obj.Key });
358
+ continue;
359
+ }
360
+ const filePath = this.keyToPath(obj.Key);
361
+ page.push({
362
+ key: obj.Key,
363
+ entry: {
327
364
  kind: "file",
328
365
  name: basename(filePath),
329
366
  path: filePath,
330
367
  size: obj.Size ?? 0,
331
368
  lastModified: obj.LastModified?.getTime() ?? 0,
332
- };
369
+ },
370
+ });
371
+ }
372
+ page.sort((x, y) => comparePaths(x.key, y.key));
373
+
374
+ for (const { key, entry } of page) {
375
+ if (recursive) {
376
+ if (entry && release(entry)) yield entry;
377
+ continue;
378
+ }
379
+ if (entry) insertInOrder(pending, entry);
380
+ const bound = releaseBound(this.keyToPath(key));
381
+ while (pending.length > 0 && comparePaths(pending[0].path, bound) < 0) {
382
+ const next = pending.shift() as FileInfo;
383
+ if (release(next)) yield next;
333
384
  }
334
385
  }
335
386
 
336
387
  continuationToken = response.NextContinuationToken;
337
388
  } while (continuationToken);
389
+
390
+ for (const entry of pending) {
391
+ if (release(entry)) yield entry;
392
+ }
338
393
  }
339
394
 
340
395
  async stats(path: string): Promise<FileStats | undefined> {
@@ -342,10 +397,7 @@ export class S3FilesApi implements FilesApi {
342
397
  const key = this.resolveKey(path);
343
398
 
344
399
  if (normalized === "/") {
345
- return {
346
- kind: "directory",
347
- lastModified: 0,
348
- };
400
+ return { kind: "directory" };
349
401
  }
350
402
 
351
403
  // Try as file first
@@ -383,10 +435,7 @@ export class S3FilesApi implements FilesApi {
383
435
  (listResponse.Contents && listResponse.Contents.length > 0) ||
384
436
  (listResponse.CommonPrefixes && listResponse.CommonPrefixes.length > 0)
385
437
  ) {
386
- return {
387
- kind: "directory",
388
- lastModified: 0,
389
- };
438
+ return { kind: "directory" };
390
439
  }
391
440
 
392
441
  return undefined;
@@ -529,3 +578,24 @@ export class S3FilesApi implements FilesApi {
529
578
  return copied;
530
579
  }
531
580
  }
581
+
582
+ /**
583
+ * Once a delimited listing has reached key position `position` (as a path), a
584
+ * directory still to come — which arrives as `D + "/"` at or after that
585
+ * position — has a path of at least this bound: the shortest prefix of
586
+ * `position` followed by a character that sorts before `/`, or `position`
587
+ * itself if there is none. Buffered entries below the bound are final.
588
+ */
589
+ function releaseBound(position: string): string {
590
+ for (let i = 0; i < position.length; i++) {
591
+ if (position.charCodeAt(i) < 0x2f) return position.slice(0, i);
592
+ }
593
+ return position;
594
+ }
595
+
596
+ function insertInOrder(entries: FileInfo[], entry: FileInfo): void {
597
+ let i = entries.length;
598
+ // Equal paths keep arrival order: a file arrives before its same-named prefix.
599
+ while (i > 0 && comparePaths(entries[i - 1].path, entry.path) > 0) i--;
600
+ entries.splice(i, 0, entry);
601
+ }