@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 +101 -11
- package/dist/cjs/index.cjs +129 -5
- package/dist/esm/index.js +125 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/list-order.d.ts +28 -0
- package/dist/list-order.d.ts.map +1 -0
- package/dist/types.d.ts +68 -12
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -6
- package/src/index.ts +10 -0
- package/src/list-order.ts +149 -0
- package/src/types.ts +71 -12
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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
|
-
*
|
|
33
|
-
*
|
|
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
|
|
36
|
-
kind:
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
/**
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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": "^
|
|
33
|
-
"rimraf": "^6.1.
|
|
34
|
-
"rolldown": "^1.
|
|
35
|
-
"typescript": "^
|
|
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
|
-
*
|
|
37
|
-
*
|
|
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
|
|
40
|
-
kind:
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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
|
|