@statewalker/webrun-files 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
@@ -13,6 +13,9 @@ For actual filesystem implementations, install one of these packages:
13
13
  - `@statewalker/webrun-files-node` - Node.js filesystem
14
14
  - `@statewalker/webrun-files-browser` - Browser File System Access API
15
15
  - `@statewalker/webrun-files-s3` - AWS S3 / S3-compatible storage
16
+ - `@statewalker/webrun-files-sqlite` - SQLite (node:sqlite, Cloudflare Durable Objects, D1)
17
+ - `@statewalker/webrun-files-http` - Serve and consume a `FilesApi` over HTTP (fetch-based stubs)
18
+ - `@statewalker/webrun-files-composite` - Mount multiple backends into a unified filesystem
16
19
 
17
20
  ## The FilesApi Interface
18
21
 
@@ -110,7 +113,7 @@ await files.write('/large.bin', generateChunks());
110
113
  ```typescript
111
114
  import { normalizePath, joinPath, dirname, basename, extname } from '@statewalker/webrun-files';
112
115
 
113
- normalizePath('//foo/../bar/./baz/'); // '/bar/baz'
116
+ normalizePath('//foo/./bar//baz/'); // '/foo/bar/baz'
114
117
  joinPath('/foo', 'bar', 'baz.txt'); // '/foo/bar/baz.txt'
115
118
  dirname('/foo/bar/baz.txt'); // '/foo/bar'
116
119
  basename('/foo/bar/baz.txt'); // 'baz.txt'
@@ -129,21 +132,43 @@ if (await files.exists('/config.json')) {
129
132
 
130
133
  ### Getting Metadata
131
134
 
135
+ `FileStats` is a discriminated union on `kind`. A file always reports a `size`
136
+ and a `lastModified`; a directory reports neither, because a directory has no
137
+ size and a modification time for one is unavailable on stores where a directory
138
+ is only a key prefix. Narrow on `kind` and the fields for that kind are then
139
+ known to be present:
140
+
132
141
  ```typescript
133
142
  const info = await files.stats('/photo.jpg');
134
- if (info) {
135
- console.log(`Size: ${info.size} bytes`);
143
+ if (info?.kind === 'file') {
144
+ console.log(`Size: ${info.size} bytes`); // number, never undefined
136
145
  console.log(`Modified: ${new Date(info.lastModified)}`);
137
- console.log(`Type: ${info.kind}`); // "file" or "directory"
146
+ } else if (info?.kind === 'directory') {
147
+ console.log('a directory'); // nothing else to report
138
148
  }
139
149
  ```
140
150
 
151
+ A zero-byte file is the file variant with `size: 0`, so test the `kind` rather
152
+ than the truthiness of `size`:
153
+
154
+ ```typescript
155
+ // WRONG - reads an empty file as though it had no size
156
+ if (info.size) { /* ... */ }
157
+
158
+ // RIGHT
159
+ if (info.kind === 'file') { /* info.size may legitimately be 0 */ }
160
+ ```
161
+
141
162
  ### Listing Directories
142
163
 
164
+ `FileInfo` is the same union plus `name` and `path`, so a listing narrows per
165
+ entry exactly as `stats()` does:
166
+
143
167
  ```typescript
144
168
  // List direct children
145
169
  for await (const entry of files.list('/documents')) {
146
- console.log(`${entry.name} (${entry.kind})`);
170
+ const size = entry.kind === 'file' ? `${entry.size} bytes` : '';
171
+ console.log(`${entry.name} (${entry.kind}) ${size}`);
147
172
  }
148
173
 
149
174
  // List recursively
@@ -152,6 +177,33 @@ for await (const entry of files.list('/project', { recursive: true })) {
152
177
  }
153
178
  ```
154
179
 
180
+ ### Listing order and resuming
181
+
182
+ Every implementation yields `list()` entries in **strictly increasing path order, compared by
183
+ Unicode code point** — the order of UTF-8 bytes, which is also how SQLite and S3 list — for
184
+ recursive and non-recursive listings alike. `ListOptions.after` resumes after any path, whether or
185
+ not it exists:
186
+
187
+ ```typescript
188
+ // Read a big directory in chunks of 256 without keeping an iterator open.
189
+ let after: string | undefined;
190
+ for (;;) {
191
+ const chunk: FileInfo[] = [];
192
+ for await (const entry of files.list('/data', { recursive: true, after })) {
193
+ chunk.push(entry);
194
+ if (chunk.length === 256) break;
195
+ }
196
+ if (chunk.length === 0) break;
197
+ await handle(chunk);
198
+ after = chunk[chunk.length - 1].path;
199
+ }
200
+ ```
201
+
202
+ Two consequences of the order: a directory comes before its descendants, but they are not
203
+ contiguous with it (`/a-x` and `/a.txt` sort between `/a` and `/a/b`, because `-` and `.` sort
204
+ before `/`); and JavaScript's `<` is not this order — it misorders characters above U+FFFF — so
205
+ compare paths with `comparePaths`.
206
+
155
207
  ### File Management
156
208
 
157
209
  ```typescript
@@ -161,19 +213,57 @@ await files.move('/temp/draft.txt', '/documents/final.txt');
161
213
  await files.remove('/old-stuff'); // Recursively deletes directories
162
214
  ```
163
215
 
216
+ ### Listing helpers for backends
217
+
218
+ ```typescript
219
+ import { comparePaths, listInPathOrder, mergeInPathOrder } from '@statewalker/webrun-files';
220
+
221
+ comparePaths(a, b); // the listing order: negative, zero or positive
222
+
223
+ // A backend that reads one directory at a time: children(dir) returns its direct
224
+ // entries in any order. Reads a directory only when the listing reaches it, and
225
+ // never one whose whole subtree sorts at or before options.after.
226
+ listInPathOrder(dir, children, { recursive, after });
227
+
228
+ // Merge listings that are each already ordered; the first stream wins a shared path.
229
+ mergeInPathOrder([streamA, streamB]);
230
+ ```
231
+
164
232
  ## Type Reference
165
233
 
166
234
  ```typescript
167
235
  import type {
168
- FilesApi, // Core interface for backends
169
- FileInfo, // Metadata from list() - includes name, path, kind, size, lastModified
170
- FileStats, // Metadata from stats() - kind, size, lastModified
171
- FileKind, // "file" | "directory"
172
- ReadOptions, // { start?: number, length?: number, signal?: AbortSignal }
173
- ListOptions, // { recursive?: boolean }
236
+ FilesApi, // Core interface for backends
237
+ FileStats, // Metadata from stats(): FileEntryStats | DirectoryEntryStats
238
+ FileEntryStats, // { kind: "file", size: number, lastModified: number }
239
+ DirectoryEntryStats, // { kind: "directory" }
240
+ FileInfo, // Metadata from list(): FileEntryInfo | DirectoryEntryInfo
241
+ FileEntryInfo, // FileEntryStats & { name, path }
242
+ DirectoryEntryInfo, // DirectoryEntryStats & { name, path }
243
+ FileEntryLocation, // { name: string, path: string }
244
+ FileKind, // "file" | "directory"
245
+ ReadOptions, // { start?: number, length?: number, signal?: AbortSignal }
246
+ ListOptions, // { recursive?: boolean, after?: string }
174
247
  } from '@statewalker/webrun-files';
175
248
  ```
176
249
 
250
+ ### Implementing a backend
251
+
252
+ An implementation must return **exactly** one variant: a file with both numbers
253
+ present, a directory with nothing but its `kind`. An implementation that knows a
254
+ directory's modification time drops it rather than offering a value that would
255
+ be present on one backend and missing on the next. The parametrized suite in
256
+ `@statewalker/webrun-files-tests` — a workspace package of this monorepo, not
257
+ published to npm — checks this at runtime: `createFilesApiTests` runs it (as
258
+ `createFileStatsConformanceTests`) for you.
259
+
260
+ A listing must also follow the order above and honour `after` exactly: the
261
+ entries after a path are the same, in the same order, as the matching suffix of
262
+ the full listing. `createFilesApiTests` checks that too
263
+ (`createListOrderTests`), against a fixture of names chosen to break naive
264
+ orderings. `listInPathOrder` gives both properties to a backend that can read
265
+ one directory at a time.
266
+
177
267
  ## License
178
268
 
179
269
  MIT
@@ -1,4 +1,4 @@
1
-
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/file-utils.ts
3
3
  /**
4
4
  * Collect async iterable chunks into a single Uint8Array.
@@ -10,7 +10,7 @@ async function collectChunks(chunks) {
10
10
  parts.push(chunk);
11
11
  totalLength += chunk.length;
12
12
  }
13
- if (parts.length === 0) return new Uint8Array(0);
13
+ if (parts.length === 0) return /* @__PURE__ */ new Uint8Array(0);
14
14
  if (parts.length === 1) return parts[0];
15
15
  const result = new Uint8Array(totalLength);
16
16
  let offset = 0;
@@ -88,7 +88,129 @@ async function readAt(files, path, buffer, bufferOffset, length, position) {
88
88
  async function writeText(files, path, content) {
89
89
  await files.write(path, [new TextEncoder().encode(content)]);
90
90
  }
91
-
91
+ //#endregion
92
+ //#region src/list-order.ts
93
+ /**
94
+ * The order every `FilesApi.list()` yields: paths compared by Unicode code
95
+ * point, which is the same as comparing their UTF-8 bytes — the order of
96
+ * SQLite's `BINARY` collation and of S3 key listings.
97
+ *
98
+ * Not JavaScript's `<`, which compares UTF-16 code units and so puts a
99
+ * character above U+FFFF (a surrogate pair) before U+E000–U+FFFF.
100
+ */
101
+ function comparePaths(a, b) {
102
+ const length = Math.min(a.length, b.length);
103
+ for (let i = 0; i < length; i++) {
104
+ const x = a.charCodeAt(i);
105
+ const y = b.charCodeAt(i);
106
+ if (x === y) continue;
107
+ const xSurrogate = x >= 55296 && x <= 57343;
108
+ if (xSurrogate !== (y >= 55296 && y <= 57343)) return xSurrogate ? 1 : -1;
109
+ return x - y;
110
+ }
111
+ return a.length - b.length;
112
+ }
113
+ /** Whether a listing resuming after `after` can still yield anything under `dir`. */
114
+ function subtreeMayFollow(dir, after) {
115
+ return after === void 0 || comparePaths(after, `${dir === "/" ? "" : dir}0`) < 0;
116
+ }
117
+ /**
118
+ * List a tree in path order for backends that read one directory at a time.
119
+ * `children(dir)` returns a directory's direct entries in any order.
120
+ *
121
+ * Pending entries sit in a min-heap keyed by path. A directory's children all
122
+ * sort after it, so pushing them when the directory is taken never yields out
123
+ * of order. Children are read only when the listing reaches their directory,
124
+ * and never for a directory whose whole subtree lies at or before `after`.
125
+ */
126
+ async function* listInPathOrder(dir, children, options = {}) {
127
+ const { recursive = false, after } = options;
128
+ const heap = new PathHeap((entry) => entry.path);
129
+ if (!subtreeMayFollow(dir, after)) return;
130
+ for (const entry of await children(dir)) heap.push(entry);
131
+ for (let entry = heap.pop(); entry; entry = heap.pop()) {
132
+ if (after === void 0 || comparePaths(entry.path, after) > 0) yield entry;
133
+ if (recursive && entry.kind === "directory" && subtreeMayFollow(entry.path, after)) for (const child of await children(entry.path)) heap.push(child);
134
+ }
135
+ }
136
+ /**
137
+ * Merge streams that are each already in path order into one. When several
138
+ * streams yield the same path, the entry from the earliest stream wins and
139
+ * the others are dropped. At most one entry per stream is pulled ahead, and
140
+ * every stream is closed when the consumer stops.
141
+ */
142
+ async function* mergeInPathOrder(streams) {
143
+ const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
144
+ const heads = [];
145
+ const done = [];
146
+ const pull = async (i) => {
147
+ const next = await iterators[i].next();
148
+ done[i] = next.done === true;
149
+ heads[i] = next.done ? void 0 : next.value;
150
+ };
151
+ try {
152
+ await Promise.all(iterators.map((_, i) => pull(i)));
153
+ for (;;) {
154
+ let min = -1;
155
+ for (let i = 0; i < heads.length; i++) {
156
+ const head = heads[i];
157
+ if (!head) continue;
158
+ const current = min === -1 ? void 0 : heads[min];
159
+ if (!current || comparePaths(head.path, current.path) < 0) min = i;
160
+ }
161
+ if (min === -1) return;
162
+ const entry = heads[min];
163
+ for (let i = min + 1; i < heads.length; i++) while (heads[i] && heads[i]?.path === entry.path) await pull(i);
164
+ heads[min] = void 0;
165
+ yield entry;
166
+ await pull(min);
167
+ }
168
+ } finally {
169
+ await Promise.all(iterators.map(async (iterator, i) => {
170
+ if (!done[i]) await iterator.return?.();
171
+ }));
172
+ }
173
+ }
174
+ /** A binary min-heap ordered by {@link comparePaths}. */
175
+ var PathHeap = class {
176
+ #items = [];
177
+ #key;
178
+ constructor(key) {
179
+ this.#key = key;
180
+ }
181
+ push(item) {
182
+ const items = this.#items;
183
+ items.push(item);
184
+ for (let i = items.length - 1; i > 0;) {
185
+ const parent = i - 1 >> 1;
186
+ if (this.#less(items[parent], items[i])) break;
187
+ [items[parent], items[i]] = [items[i], items[parent]];
188
+ i = parent;
189
+ }
190
+ }
191
+ pop() {
192
+ const items = this.#items;
193
+ const top = items[0];
194
+ const last = items.pop();
195
+ if (items.length > 0 && last !== void 0) {
196
+ items[0] = last;
197
+ for (let i = 0;;) {
198
+ const left = 2 * i + 1;
199
+ const right = left + 1;
200
+ let smallest = i;
201
+ if (left < items.length && this.#less(items[left], items[smallest])) smallest = left;
202
+ if (right < items.length && this.#less(items[right], items[smallest])) smallest = right;
203
+ if (smallest === i) break;
204
+ [items[smallest], items[i]] = [items[i], items[smallest]];
205
+ i = smallest;
206
+ }
207
+ }
208
+ return top;
209
+ }
210
+ #less(a, b) {
211
+ return comparePaths(this.#key(a), this.#key(b)) < 0;
212
+ }
213
+ };
92
214
  //#endregion
93
215
  //#region src/path-utils.ts
94
216
  /**
@@ -141,12 +263,14 @@ function extname(path) {
141
263
  if (dotIndex <= 0) return "";
142
264
  return name.substring(dotIndex);
143
265
  }
144
-
145
266
  //#endregion
146
267
  exports.basename = basename;
268
+ exports.comparePaths = comparePaths;
147
269
  exports.dirname = dirname;
148
270
  exports.extname = extname;
149
271
  exports.joinPath = joinPath;
272
+ exports.listInPathOrder = listInPathOrder;
273
+ exports.mergeInPathOrder = mergeInPathOrder;
150
274
  exports.normalizePath = normalizePath;
151
275
  exports.readAt = readAt;
152
276
  exports.readFile = readFile;
@@ -154,4 +278,4 @@ exports.readRange = readRange;
154
278
  exports.readText = readText;
155
279
  exports.tryReadFile = tryReadFile;
156
280
  exports.tryReadText = tryReadText;
157
- exports.writeText = writeText;
281
+ exports.writeText = writeText;
package/dist/esm/index.js CHANGED
@@ -9,7 +9,7 @@ async function collectChunks(chunks) {
9
9
  parts.push(chunk);
10
10
  totalLength += chunk.length;
11
11
  }
12
- if (parts.length === 0) return new Uint8Array(0);
12
+ if (parts.length === 0) return /* @__PURE__ */ new Uint8Array(0);
13
13
  if (parts.length === 1) return parts[0];
14
14
  const result = new Uint8Array(totalLength);
15
15
  let offset = 0;
@@ -87,7 +87,129 @@ async function readAt(files, path, buffer, bufferOffset, length, position) {
87
87
  async function writeText(files, path, content) {
88
88
  await files.write(path, [new TextEncoder().encode(content)]);
89
89
  }
90
-
90
+ //#endregion
91
+ //#region src/list-order.ts
92
+ /**
93
+ * The order every `FilesApi.list()` yields: paths compared by Unicode code
94
+ * point, which is the same as comparing their UTF-8 bytes — the order of
95
+ * SQLite's `BINARY` collation and of S3 key listings.
96
+ *
97
+ * Not JavaScript's `<`, which compares UTF-16 code units and so puts a
98
+ * character above U+FFFF (a surrogate pair) before U+E000–U+FFFF.
99
+ */
100
+ function comparePaths(a, b) {
101
+ const length = Math.min(a.length, b.length);
102
+ for (let i = 0; i < length; i++) {
103
+ const x = a.charCodeAt(i);
104
+ const y = b.charCodeAt(i);
105
+ if (x === y) continue;
106
+ const xSurrogate = x >= 55296 && x <= 57343;
107
+ if (xSurrogate !== (y >= 55296 && y <= 57343)) return xSurrogate ? 1 : -1;
108
+ return x - y;
109
+ }
110
+ return a.length - b.length;
111
+ }
112
+ /** Whether a listing resuming after `after` can still yield anything under `dir`. */
113
+ function subtreeMayFollow(dir, after) {
114
+ return after === void 0 || comparePaths(after, `${dir === "/" ? "" : dir}0`) < 0;
115
+ }
116
+ /**
117
+ * List a tree in path order for backends that read one directory at a time.
118
+ * `children(dir)` returns a directory's direct entries in any order.
119
+ *
120
+ * Pending entries sit in a min-heap keyed by path. A directory's children all
121
+ * sort after it, so pushing them when the directory is taken never yields out
122
+ * of order. Children are read only when the listing reaches their directory,
123
+ * and never for a directory whose whole subtree lies at or before `after`.
124
+ */
125
+ async function* listInPathOrder(dir, children, options = {}) {
126
+ const { recursive = false, after } = options;
127
+ const heap = new PathHeap((entry) => entry.path);
128
+ if (!subtreeMayFollow(dir, after)) return;
129
+ for (const entry of await children(dir)) heap.push(entry);
130
+ for (let entry = heap.pop(); entry; entry = heap.pop()) {
131
+ if (after === void 0 || comparePaths(entry.path, after) > 0) yield entry;
132
+ if (recursive && entry.kind === "directory" && subtreeMayFollow(entry.path, after)) for (const child of await children(entry.path)) heap.push(child);
133
+ }
134
+ }
135
+ /**
136
+ * Merge streams that are each already in path order into one. When several
137
+ * streams yield the same path, the entry from the earliest stream wins and
138
+ * the others are dropped. At most one entry per stream is pulled ahead, and
139
+ * every stream is closed when the consumer stops.
140
+ */
141
+ async function* mergeInPathOrder(streams) {
142
+ const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
143
+ const heads = [];
144
+ const done = [];
145
+ const pull = async (i) => {
146
+ const next = await iterators[i].next();
147
+ done[i] = next.done === true;
148
+ heads[i] = next.done ? void 0 : next.value;
149
+ };
150
+ try {
151
+ await Promise.all(iterators.map((_, i) => pull(i)));
152
+ for (;;) {
153
+ let min = -1;
154
+ for (let i = 0; i < heads.length; i++) {
155
+ const head = heads[i];
156
+ if (!head) continue;
157
+ const current = min === -1 ? void 0 : heads[min];
158
+ if (!current || comparePaths(head.path, current.path) < 0) min = i;
159
+ }
160
+ if (min === -1) return;
161
+ const entry = heads[min];
162
+ for (let i = min + 1; i < heads.length; i++) while (heads[i] && heads[i]?.path === entry.path) await pull(i);
163
+ heads[min] = void 0;
164
+ yield entry;
165
+ await pull(min);
166
+ }
167
+ } finally {
168
+ await Promise.all(iterators.map(async (iterator, i) => {
169
+ if (!done[i]) await iterator.return?.();
170
+ }));
171
+ }
172
+ }
173
+ /** A binary min-heap ordered by {@link comparePaths}. */
174
+ var PathHeap = class {
175
+ #items = [];
176
+ #key;
177
+ constructor(key) {
178
+ this.#key = key;
179
+ }
180
+ push(item) {
181
+ const items = this.#items;
182
+ items.push(item);
183
+ for (let i = items.length - 1; i > 0;) {
184
+ const parent = i - 1 >> 1;
185
+ if (this.#less(items[parent], items[i])) break;
186
+ [items[parent], items[i]] = [items[i], items[parent]];
187
+ i = parent;
188
+ }
189
+ }
190
+ pop() {
191
+ const items = this.#items;
192
+ const top = items[0];
193
+ const last = items.pop();
194
+ if (items.length > 0 && last !== void 0) {
195
+ items[0] = last;
196
+ for (let i = 0;;) {
197
+ const left = 2 * i + 1;
198
+ const right = left + 1;
199
+ let smallest = i;
200
+ if (left < items.length && this.#less(items[left], items[smallest])) smallest = left;
201
+ if (right < items.length && this.#less(items[right], items[smallest])) smallest = right;
202
+ if (smallest === i) break;
203
+ [items[smallest], items[i]] = [items[i], items[smallest]];
204
+ i = smallest;
205
+ }
206
+ }
207
+ return top;
208
+ }
209
+ #less(a, b) {
210
+ return comparePaths(this.#key(a), this.#key(b)) < 0;
211
+ }
212
+ };
91
213
  //#endregion
92
214
  //#region src/path-utils.ts
93
215
  /**
@@ -140,6 +262,5 @@ function extname(path) {
140
262
  if (dotIndex <= 0) return "";
141
263
  return name.substring(dotIndex);
142
264
  }
143
-
144
265
  //#endregion
145
- export { basename, dirname, extname, joinPath, normalizePath, readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText };
266
+ export { basename, comparePaths, dirname, extname, joinPath, listInPathOrder, mergeInPathOrder, normalizePath, readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText };
package/dist/index.d.ts CHANGED
@@ -11,8 +11,12 @@
11
11
  * - @statewalker/webrun-files-node (Node.js)
12
12
  * - @statewalker/webrun-files-browser (browser)
13
13
  * - @statewalker/webrun-files-s3 (S3)
14
+ * - @statewalker/webrun-files-sqlite (SQLite: node:sqlite, Durable Objects, D1)
15
+ * - @statewalker/webrun-files-http (a FilesApi served and consumed over HTTP)
16
+ * - @statewalker/webrun-files-composite (mount several backends as one tree)
14
17
  */
15
18
  export { readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText, } from "./file-utils.js";
19
+ export { comparePaths, listInPathOrder, mergeInPathOrder } from "./list-order.js";
16
20
  export { basename, dirname, extname, joinPath, normalizePath, } from "./path-utils.js";
17
- export type { FileInfo, FileKind, FileStats, FilesApi, ListOptions, ReadOptions, } from "./types.js";
21
+ export type { DirectoryEntryInfo, DirectoryEntryStats, FileEntryInfo, FileEntryLocation, FileEntryStats, FileInfo, FileKind, FileStats, FilesApi, ListOptions, ReadOptions, } from "./types.js";
18
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EACL,MAAM,EACN,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACX,SAAS,GACV,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EACL,QAAQ,EACR,OAAO,EACP,OAAO,EACP,QAAQ,EACR,aAAa,GACd,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,GACZ,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,EACL,MAAM,EACN,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,EACX,SAAS,GACV,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAElF,OAAO,EACL,QAAQ,EACR,OAAO,EACP,OAAO,EACP,QAAQ,EACR,aAAa,GACd,MAAM,iBAAiB,CAAC;AAEzB,YAAY,EACV,kBAAkB,EAClB,mBAAmB,EACnB,aAAa,EACb,iBAAiB,EACjB,cAAc,EACd,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,QAAQ,EACR,WAAW,EACX,WAAW,GACZ,MAAM,YAAY,CAAC"}
@@ -0,0 +1,28 @@
1
+ import type { FileInfo, ListOptions } from "./types.js";
2
+ /**
3
+ * The order every `FilesApi.list()` yields: paths compared by Unicode code
4
+ * point, which is the same as comparing their UTF-8 bytes — the order of
5
+ * SQLite's `BINARY` collation and of S3 key listings.
6
+ *
7
+ * Not JavaScript's `<`, which compares UTF-16 code units and so puts a
8
+ * character above U+FFFF (a surrogate pair) before U+E000–U+FFFF.
9
+ */
10
+ export declare function comparePaths(a: string, b: string): number;
11
+ /**
12
+ * List a tree in path order for backends that read one directory at a time.
13
+ * `children(dir)` returns a directory's direct entries in any order.
14
+ *
15
+ * Pending entries sit in a min-heap keyed by path. A directory's children all
16
+ * sort after it, so pushing them when the directory is taken never yields out
17
+ * of order. Children are read only when the listing reaches their directory,
18
+ * and never for a directory whose whole subtree lies at or before `after`.
19
+ */
20
+ export declare function listInPathOrder(dir: string, children: (dir: string) => Promise<Iterable<FileInfo>> | Iterable<FileInfo>, options?: ListOptions): AsyncGenerator<FileInfo>;
21
+ /**
22
+ * Merge streams that are each already in path order into one. When several
23
+ * streams yield the same path, the entry from the earliest stream wins and
24
+ * the others are dropped. At most one entry per stream is pulled ahead, and
25
+ * every stream is closed when the consumer stops.
26
+ */
27
+ export declare function mergeInPathOrder(streams: AsyncIterable<FileInfo>[]): AsyncGenerator<FileInfo>;
28
+ //# sourceMappingURL=list-order.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list-order.d.ts","sourceRoot":"","sources":["../src/list-order.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAExD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAczD;AAQD;;;;;;;;GAQG;AACH,wBAAuB,eAAe,CACpC,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,GAAG,QAAQ,CAAC,QAAQ,CAAC,EAC3E,OAAO,GAAE,WAAgB,GACxB,cAAc,CAAC,QAAQ,CAAC,CAY1B;AAED;;;;;GAKG;AACH,wBAAuB,gBAAgB,CACrC,OAAO,EAAE,aAAa,CAAC,QAAQ,CAAC,EAAE,GACjC,cAAc,CAAC,QAAQ,CAAC,CAoC1B"}
package/dist/types.d.ts CHANGED
@@ -27,27 +27,79 @@ export interface ReadOptions {
27
27
  export interface ListOptions {
28
28
  /** If true, lists all descendants recursively. Defaults to false. */
29
29
  recursive?: boolean;
30
+ /**
31
+ * Yield only entries whose path sorts strictly after this one, in
32
+ * `comparePaths` order. The path need not exist, and may lie outside
33
+ * the listed directory: before it, everything is yielded; past it, nothing.
34
+ *
35
+ * Resuming with the last path yielded continues a listing exactly where it
36
+ * stopped, without holding an iterator open between the calls.
37
+ */
38
+ after?: string;
30
39
  }
31
40
  /**
32
- * File or directory metadata returned by stats().
33
- * Simplified type without path information.
41
+ * Metadata of a regular file.
42
+ *
43
+ * `size` and `lastModified` are REQUIRED. Every storage that can hold a file
44
+ * can say how large it is and when it last changed; an implementation that
45
+ * cannot is claiming less than it must, and the omission must surface as a
46
+ * failure rather than as an `undefined` a consumer has to defend against.
34
47
  */
35
- export interface FileStats {
36
- kind: FileKind;
37
- size?: number;
38
- lastModified?: number;
48
+ export interface FileEntryStats {
49
+ kind: "file";
50
+ /** Length in bytes. A zero-byte file is `0`, never `undefined`. */
51
+ size: number;
52
+ /** Modification time as milliseconds since the epoch. */
53
+ lastModified: number;
39
54
  }
40
55
  /**
41
- * File or directory information returned by list().
42
- * Includes name and path for directory traversal.
56
+ * Metadata of a directory.
57
+ *
58
+ * Deliberately carries nothing else. A directory has no size, and a
59
+ * modification time is available on some storages (memory, a local
60
+ * filesystem) and unavailable on others (an object store, where a directory
61
+ * is a common prefix that exists only as an artefact of key naming). Neither
62
+ * value may be relied upon, so neither is part of the contract: an
63
+ * implementation that happens to know one drops it here instead of leaking a
64
+ * value consumers would have to treat as optional anyway.
43
65
  */
44
- export interface FileInfo {
66
+ export interface DirectoryEntryStats {
67
+ kind: "directory";
68
+ }
69
+ /**
70
+ * File or directory metadata returned by `stats()`.
71
+ *
72
+ * A DISCRIMINATED UNION on `kind`, not a single shape with optional fields.
73
+ * The optionality this replaces never expressed what a given implementation
74
+ * could report — it expressed a property of the entry kind, collapsed into
75
+ * one type because `kind` was an ordinary field. Narrow on `kind` and the
76
+ * fields that exist for that kind are then known to be present:
77
+ *
78
+ * @example
79
+ * const stats = await api.stats("/some/path");
80
+ * if (stats?.kind === "file") {
81
+ * console.log(stats.size, stats.lastModified); // both `number`
82
+ * }
83
+ */
84
+ export type FileStats = FileEntryStats | DirectoryEntryStats;
85
+ /** Name and path carried by every entry a `list()` yields. */
86
+ export interface FileEntryLocation {
45
87
  name: string;
46
88
  path: string;
47
- kind: FileKind;
48
- size?: number;
49
- lastModified?: number;
50
89
  }
90
+ /** A file yielded by `list()`. */
91
+ export interface FileEntryInfo extends FileEntryStats, FileEntryLocation {
92
+ }
93
+ /** A directory yielded by `list()`. */
94
+ export interface DirectoryEntryInfo extends DirectoryEntryStats, FileEntryLocation {
95
+ }
96
+ /**
97
+ * File or directory information returned by `list()`.
98
+ *
99
+ * The same union as {@link FileStats}, plus `name` and `path`, so a listing
100
+ * consumer narrows per entry exactly as a `stats()` consumer does.
101
+ */
102
+ export type FileInfo = FileEntryInfo | DirectoryEntryInfo;
51
103
  /**
52
104
  * Cross-platform filesystem abstraction interface.
53
105
  *
@@ -73,6 +125,10 @@ export interface FilesApi {
73
125
  /**
74
126
  * List directory contents.
75
127
  * Returns empty iterable for non-existent or non-directory paths.
128
+ *
129
+ * Entries are yielded in strictly increasing path order, compared by
130
+ * Unicode code point (`comparePaths`) — the same for recursive and
131
+ * non-recursive listings. `options.after` resumes after a given path.
76
132
  */
77
133
  list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
78
134
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;AAE5C;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,QAAQ,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACvB;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;IAErE;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9F;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnC;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAC;IAEnE;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;IAEpD;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvC;;;OAGG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvC;;;OAGG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvD;;;OAGG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxD"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;AAE5C;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,qEAAqE;IACrE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB;;;;;;;OAOG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,YAAY,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,WAAW,CAAC;CACnB;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,SAAS,GAAG,cAAc,GAAG,mBAAmB,CAAC;AAE7D,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAED,kCAAkC;AAClC,MAAM,WAAW,aAAc,SAAQ,cAAc,EAAE,iBAAiB;CAAG;AAE3E,uCAAuC;AACvC,MAAM,WAAW,kBAAmB,SAAQ,mBAAmB,EAAE,iBAAiB;CAAG;AAErF;;;;;GAKG;AACH,MAAM,MAAM,QAAQ,GAAG,aAAa,GAAG,kBAAkB,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,WAAW,QAAQ;IACvB;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;IAErE;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9F;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEnC;;;;;;;OAOG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,GAAG,aAAa,CAAC,QAAQ,CAAC,CAAC;IAEnE;;;OAGG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC,CAAC;IAEpD;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvC;;;OAGG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvC;;;OAGG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAEvD;;;OAGG;IACH,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACxD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files",
3
- "version": "0.7.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilesApi interface and utilities for cross-platform filesystem abstraction",
@@ -29,10 +29,11 @@
29
29
  "src"
30
30
  ],
31
31
  "devDependencies": {
32
- "@types/node": "^24.10.1",
33
- "rimraf": "^6.1.2",
34
- "rolldown": "^1.0.0-beta.53",
35
- "typescript": "^5.6.0"
32
+ "@types/node": "^26.2.0",
33
+ "rimraf": "^6.1.3",
34
+ "rolldown": "^1.2.4",
35
+ "typescript": "^7.0.2",
36
+ "vitest": "^4.1.10"
36
37
  },
37
38
  "sideEffects": false,
38
39
  "publishConfig": {
@@ -40,6 +41,7 @@
40
41
  },
41
42
  "scripts": {
42
43
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
43
- "lint": "biome lint src"
44
+ "lint": "biome lint src",
45
+ "test": "vitest run"
44
46
  }
45
47
  }
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@
11
11
  * - @statewalker/webrun-files-node (Node.js)
12
12
  * - @statewalker/webrun-files-browser (browser)
13
13
  * - @statewalker/webrun-files-s3 (S3)
14
+ * - @statewalker/webrun-files-sqlite (SQLite: node:sqlite, Durable Objects, D1)
15
+ * - @statewalker/webrun-files-http (a FilesApi served and consumed over HTTP)
16
+ * - @statewalker/webrun-files-composite (mount several backends as one tree)
14
17
  */
15
18
 
16
19
  // File utilities
@@ -23,6 +26,8 @@ export {
23
26
  tryReadText,
24
27
  writeText,
25
28
  } from "./file-utils.js";
29
+ // Listing order
30
+ export { comparePaths, listInPathOrder, mergeInPathOrder } from "./list-order.js";
26
31
  // Path utilities
27
32
  export {
28
33
  basename,
@@ -33,6 +38,11 @@ export {
33
38
  } from "./path-utils.js";
34
39
  // Core types
35
40
  export type {
41
+ DirectoryEntryInfo,
42
+ DirectoryEntryStats,
43
+ FileEntryInfo,
44
+ FileEntryLocation,
45
+ FileEntryStats,
36
46
  FileInfo,
37
47
  FileKind,
38
48
  FileStats,
@@ -0,0 +1,149 @@
1
+ import type { FileInfo, ListOptions } from "./types.js";
2
+
3
+ /**
4
+ * The order every `FilesApi.list()` yields: paths compared by Unicode code
5
+ * point, which is the same as comparing their UTF-8 bytes — the order of
6
+ * SQLite's `BINARY` collation and of S3 key listings.
7
+ *
8
+ * Not JavaScript's `<`, which compares UTF-16 code units and so puts a
9
+ * character above U+FFFF (a surrogate pair) before U+E000–U+FFFF.
10
+ */
11
+ export function comparePaths(a: string, b: string): number {
12
+ const length = Math.min(a.length, b.length);
13
+ for (let i = 0; i < length; i++) {
14
+ const x = a.charCodeAt(i);
15
+ const y = b.charCodeAt(i);
16
+ if (x === y) continue;
17
+ const xSurrogate = x >= 0xd800 && x <= 0xdfff;
18
+ const ySurrogate = y >= 0xd800 && y <= 0xdfff;
19
+ // A surrogate stands for a code point above U+FFFF, so it outranks any
20
+ // other unit; two surrogates compare in code point order as they are.
21
+ if (xSurrogate !== ySurrogate) return xSurrogate ? 1 : -1;
22
+ return x - y;
23
+ }
24
+ return a.length - b.length;
25
+ }
26
+
27
+ /** Whether a listing resuming after `after` can still yield anything under `dir`. */
28
+ function subtreeMayFollow(dir: string, after: string | undefined): boolean {
29
+ // Every descendant of `dir` lies between `dir + "/"` and `dir + "0"`.
30
+ return after === undefined || comparePaths(after, `${dir === "/" ? "" : dir}0`) < 0;
31
+ }
32
+
33
+ /**
34
+ * List a tree in path order for backends that read one directory at a time.
35
+ * `children(dir)` returns a directory's direct entries in any order.
36
+ *
37
+ * Pending entries sit in a min-heap keyed by path. A directory's children all
38
+ * sort after it, so pushing them when the directory is taken never yields out
39
+ * of order. Children are read only when the listing reaches their directory,
40
+ * and never for a directory whose whole subtree lies at or before `after`.
41
+ */
42
+ export async function* listInPathOrder(
43
+ dir: string,
44
+ children: (dir: string) => Promise<Iterable<FileInfo>> | Iterable<FileInfo>,
45
+ options: ListOptions = {},
46
+ ): AsyncGenerator<FileInfo> {
47
+ const { recursive = false, after } = options;
48
+ const heap = new PathHeap<FileInfo>((entry) => entry.path);
49
+ if (!subtreeMayFollow(dir, after)) return;
50
+ for (const entry of await children(dir)) heap.push(entry);
51
+
52
+ for (let entry = heap.pop(); entry; entry = heap.pop()) {
53
+ if (after === undefined || comparePaths(entry.path, after) > 0) yield entry;
54
+ if (recursive && entry.kind === "directory" && subtreeMayFollow(entry.path, after)) {
55
+ for (const child of await children(entry.path)) heap.push(child);
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Merge streams that are each already in path order into one. When several
62
+ * streams yield the same path, the entry from the earliest stream wins and
63
+ * the others are dropped. At most one entry per stream is pulled ahead, and
64
+ * every stream is closed when the consumer stops.
65
+ */
66
+ export async function* mergeInPathOrder(
67
+ streams: AsyncIterable<FileInfo>[],
68
+ ): AsyncGenerator<FileInfo> {
69
+ const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
70
+ const heads: (FileInfo | undefined)[] = [];
71
+ const done: boolean[] = [];
72
+ const pull = async (i: number) => {
73
+ const next = await iterators[i].next();
74
+ done[i] = next.done === true;
75
+ heads[i] = next.done ? undefined : next.value;
76
+ };
77
+ try {
78
+ await Promise.all(iterators.map((_, i) => pull(i)));
79
+ for (;;) {
80
+ let min = -1;
81
+ for (let i = 0; i < heads.length; i++) {
82
+ const head = heads[i];
83
+ if (!head) continue;
84
+ const current = min === -1 ? undefined : heads[min];
85
+ if (!current || comparePaths(head.path, current.path) < 0) min = i;
86
+ }
87
+ if (min === -1) return;
88
+ const entry = heads[min] as FileInfo;
89
+ // Drop the same path from later streams before yielding.
90
+ for (let i = min + 1; i < heads.length; i++) {
91
+ while (heads[i] && heads[i]?.path === entry.path) await pull(i);
92
+ }
93
+ heads[min] = undefined;
94
+ yield entry;
95
+ await pull(min);
96
+ }
97
+ } finally {
98
+ await Promise.all(
99
+ iterators.map(async (iterator, i) => {
100
+ if (!done[i]) await iterator.return?.();
101
+ }),
102
+ );
103
+ }
104
+ }
105
+
106
+ /** A binary min-heap ordered by {@link comparePaths}. */
107
+ class PathHeap<T> {
108
+ readonly #items: T[] = [];
109
+ readonly #key: (item: T) => string;
110
+
111
+ constructor(key: (item: T) => string) {
112
+ this.#key = key;
113
+ }
114
+
115
+ push(item: T): void {
116
+ const items = this.#items;
117
+ items.push(item);
118
+ for (let i = items.length - 1; i > 0; ) {
119
+ const parent = (i - 1) >> 1;
120
+ if (this.#less(items[parent], items[i])) break;
121
+ [items[parent], items[i]] = [items[i], items[parent]];
122
+ i = parent;
123
+ }
124
+ }
125
+
126
+ pop(): T | undefined {
127
+ const items = this.#items;
128
+ const top = items[0];
129
+ const last = items.pop();
130
+ if (items.length > 0 && last !== undefined) {
131
+ items[0] = last;
132
+ for (let i = 0; ; ) {
133
+ const left = 2 * i + 1;
134
+ const right = left + 1;
135
+ let smallest = i;
136
+ if (left < items.length && this.#less(items[left], items[smallest])) smallest = left;
137
+ if (right < items.length && this.#less(items[right], items[smallest])) smallest = right;
138
+ if (smallest === i) break;
139
+ [items[smallest], items[i]] = [items[i], items[smallest]];
140
+ i = smallest;
141
+ }
142
+ }
143
+ return top;
144
+ }
145
+
146
+ #less(a: T, b: T): boolean {
147
+ return comparePaths(this.#key(a), this.#key(b)) < 0;
148
+ }
149
+ }
package/src/types.ts CHANGED
@@ -30,30 +30,85 @@ export interface ReadOptions {
30
30
  export interface ListOptions {
31
31
  /** If true, lists all descendants recursively. Defaults to false. */
32
32
  recursive?: boolean;
33
+ /**
34
+ * Yield only entries whose path sorts strictly after this one, in
35
+ * `comparePaths` order. The path need not exist, and may lie outside
36
+ * the listed directory: before it, everything is yielded; past it, nothing.
37
+ *
38
+ * Resuming with the last path yielded continues a listing exactly where it
39
+ * stopped, without holding an iterator open between the calls.
40
+ */
41
+ after?: string;
33
42
  }
34
43
 
35
44
  /**
36
- * File or directory metadata returned by stats().
37
- * Simplified type without path information.
45
+ * Metadata of a regular file.
46
+ *
47
+ * `size` and `lastModified` are REQUIRED. Every storage that can hold a file
48
+ * can say how large it is and when it last changed; an implementation that
49
+ * cannot is claiming less than it must, and the omission must surface as a
50
+ * failure rather than as an `undefined` a consumer has to defend against.
38
51
  */
39
- export interface FileStats {
40
- kind: FileKind;
41
- size?: number;
42
- lastModified?: number;
52
+ export interface FileEntryStats {
53
+ kind: "file";
54
+ /** Length in bytes. A zero-byte file is `0`, never `undefined`. */
55
+ size: number;
56
+ /** Modification time as milliseconds since the epoch. */
57
+ lastModified: number;
43
58
  }
44
59
 
45
60
  /**
46
- * File or directory information returned by list().
47
- * Includes name and path for directory traversal.
61
+ * Metadata of a directory.
62
+ *
63
+ * Deliberately carries nothing else. A directory has no size, and a
64
+ * modification time is available on some storages (memory, a local
65
+ * filesystem) and unavailable on others (an object store, where a directory
66
+ * is a common prefix that exists only as an artefact of key naming). Neither
67
+ * value may be relied upon, so neither is part of the contract: an
68
+ * implementation that happens to know one drops it here instead of leaking a
69
+ * value consumers would have to treat as optional anyway.
70
+ */
71
+ export interface DirectoryEntryStats {
72
+ kind: "directory";
73
+ }
74
+
75
+ /**
76
+ * File or directory metadata returned by `stats()`.
77
+ *
78
+ * A DISCRIMINATED UNION on `kind`, not a single shape with optional fields.
79
+ * The optionality this replaces never expressed what a given implementation
80
+ * could report — it expressed a property of the entry kind, collapsed into
81
+ * one type because `kind` was an ordinary field. Narrow on `kind` and the
82
+ * fields that exist for that kind are then known to be present:
83
+ *
84
+ * @example
85
+ * const stats = await api.stats("/some/path");
86
+ * if (stats?.kind === "file") {
87
+ * console.log(stats.size, stats.lastModified); // both `number`
88
+ * }
48
89
  */
49
- export interface FileInfo {
90
+ export type FileStats = FileEntryStats | DirectoryEntryStats;
91
+
92
+ /** Name and path carried by every entry a `list()` yields. */
93
+ export interface FileEntryLocation {
50
94
  name: string;
51
95
  path: string;
52
- kind: FileKind;
53
- size?: number;
54
- lastModified?: number;
55
96
  }
56
97
 
98
+ /** A file yielded by `list()`. */
99
+ export interface FileEntryInfo extends FileEntryStats, FileEntryLocation {}
100
+
101
+ /** A directory yielded by `list()`. */
102
+ export interface DirectoryEntryInfo extends DirectoryEntryStats, FileEntryLocation {}
103
+
104
+ /**
105
+ * File or directory information returned by `list()`.
106
+ *
107
+ * The same union as {@link FileStats}, plus `name` and `path`, so a listing
108
+ * consumer narrows per entry exactly as a `stats()` consumer does.
109
+ */
110
+ export type FileInfo = FileEntryInfo | DirectoryEntryInfo;
111
+
57
112
  /**
58
113
  * Cross-platform filesystem abstraction interface.
59
114
  *
@@ -82,6 +137,10 @@ export interface FilesApi {
82
137
  /**
83
138
  * List directory contents.
84
139
  * Returns empty iterable for non-existent or non-directory paths.
140
+ *
141
+ * Entries are yielded in strictly increasing path order, compared by
142
+ * Unicode code point (`comparePaths`) — the same for recursive and
143
+ * non-recursive listings. `options.after` resumes after a given path.
85
144
  */
86
145
  list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
87
146