@statewalker/webrun-files 0.9.0 → 0.10.1

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,8 @@ 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)
16
18
  - `@statewalker/webrun-files-composite` - Mount multiple backends into a unified filesystem
17
19
 
18
20
  ## The FilesApi Interface
@@ -175,6 +177,33 @@ for await (const entry of files.list('/project', { recursive: true })) {
175
177
  }
176
178
  ```
177
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
+
178
207
  ### File Management
179
208
 
180
209
  ```typescript
@@ -184,6 +213,22 @@ await files.move('/temp/draft.txt', '/documents/final.txt');
184
213
  await files.remove('/old-stuff'); // Recursively deletes directories
185
214
  ```
186
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
+
187
232
  ## Type Reference
188
233
 
189
234
  ```typescript
@@ -198,7 +243,7 @@ import type {
198
243
  FileEntryLocation, // { name: string, path: string }
199
244
  FileKind, // "file" | "directory"
200
245
  ReadOptions, // { start?: number, length?: number, signal?: AbortSignal }
201
- ListOptions, // { recursive?: boolean }
246
+ ListOptions, // { recursive?: boolean, after?: string }
202
247
  } from '@statewalker/webrun-files';
203
248
  ```
204
249
 
@@ -208,8 +253,16 @@ An implementation must return **exactly** one variant: a file with both numbers
208
253
  present, a directory with nothing but its `kind`. An implementation that knows a
209
254
  directory's modification time drops it rather than offering a value that would
210
255
  be present on one backend and missing on the next. The parametrized suite in
211
- `@statewalker/webrun-files-tests` checks this at runtime, and
212
- `createFilesApiTests` runs it for you.
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.
213
266
 
214
267
  ## License
215
268
 
@@ -89,6 +89,129 @@ 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
+ };
214
+ //#endregion
92
215
  //#region src/path-utils.ts
93
216
  /**
94
217
  * Path manipulation utilities for virtual filesystem paths.
@@ -142,9 +265,12 @@ function extname(path) {
142
265
  }
143
266
  //#endregion
144
267
  exports.basename = basename;
268
+ exports.comparePaths = comparePaths;
145
269
  exports.dirname = dirname;
146
270
  exports.extname = extname;
147
271
  exports.joinPath = joinPath;
272
+ exports.listInPathOrder = listInPathOrder;
273
+ exports.mergeInPathOrder = mergeInPathOrder;
148
274
  exports.normalizePath = normalizePath;
149
275
  exports.readAt = readAt;
150
276
  exports.readFile = readFile;
package/dist/esm/index.js CHANGED
@@ -88,6 +88,129 @@ 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
+ };
213
+ //#endregion
91
214
  //#region src/path-utils.ts
92
215
  /**
93
216
  * Path manipulation utilities for virtual filesystem paths.
@@ -140,4 +263,4 @@ function extname(path) {
140
263
  return name.substring(dotIndex);
141
264
  }
142
265
  //#endregion
143
- 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
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,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"}
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,6 +27,15 @@ 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
41
  * Metadata of a regular file.
@@ -116,6 +125,10 @@ export interface FilesApi {
116
125
  /**
117
126
  * List directory contents.
118
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.
119
132
  */
120
133
  list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
121
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;;;;;;;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;;;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.9.0",
3
+ "version": "0.10.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilesApi interface and utilities for cross-platform filesystem abstraction",
@@ -19,6 +19,7 @@
19
19
  "types": "./dist/index.d.ts",
20
20
  "exports": {
21
21
  ".": {
22
+ "source": "./src/index.ts",
22
23
  "types": "./dist/index.d.ts",
23
24
  "import": "./dist/esm/index.js",
24
25
  "require": "./dist/cjs/index.cjs"
@@ -29,10 +30,11 @@
29
30
  "src"
30
31
  ],
31
32
  "devDependencies": {
32
- "@types/node": "^26.2.0",
33
+ "@types/node": "^26.6.4",
33
34
  "rimraf": "^6.1.3",
34
- "rolldown": "^1.2.4",
35
- "typescript": "^7.0.2"
35
+ "rolldown": "^1.2.12",
36
+ "typescript": "^7.0.2",
37
+ "vitest": "^5.0.3"
36
38
  },
37
39
  "sideEffects": false,
38
40
  "publishConfig": {
@@ -40,6 +42,7 @@
40
42
  },
41
43
  "scripts": {
42
44
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
43
- "lint": "biome lint src"
45
+ "lint": "biome lint src",
46
+ "test": "vitest run"
44
47
  }
45
48
  }
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,
@@ -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,6 +30,15 @@ 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
  /**
@@ -128,6 +137,10 @@ export interface FilesApi {
128
137
  /**
129
138
  * List directory contents.
130
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.
131
144
  */
132
145
  list(path: string, options?: ListOptions): AsyncIterable<FileInfo>;
133
146