@statewalker/webrun-files 0.7.0 → 0.9.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,7 @@ 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-composite` - Mount multiple backends into a unified filesystem
16
17
 
17
18
  ## The FilesApi Interface
18
19
 
@@ -110,7 +111,7 @@ await files.write('/large.bin', generateChunks());
110
111
  ```typescript
111
112
  import { normalizePath, joinPath, dirname, basename, extname } from '@statewalker/webrun-files';
112
113
 
113
- normalizePath('//foo/../bar/./baz/'); // '/bar/baz'
114
+ normalizePath('//foo/./bar//baz/'); // '/foo/bar/baz'
114
115
  joinPath('/foo', 'bar', 'baz.txt'); // '/foo/bar/baz.txt'
115
116
  dirname('/foo/bar/baz.txt'); // '/foo/bar'
116
117
  basename('/foo/bar/baz.txt'); // 'baz.txt'
@@ -129,21 +130,43 @@ if (await files.exists('/config.json')) {
129
130
 
130
131
  ### Getting Metadata
131
132
 
133
+ `FileStats` is a discriminated union on `kind`. A file always reports a `size`
134
+ and a `lastModified`; a directory reports neither, because a directory has no
135
+ size and a modification time for one is unavailable on stores where a directory
136
+ is only a key prefix. Narrow on `kind` and the fields for that kind are then
137
+ known to be present:
138
+
132
139
  ```typescript
133
140
  const info = await files.stats('/photo.jpg');
134
- if (info) {
135
- console.log(`Size: ${info.size} bytes`);
141
+ if (info?.kind === 'file') {
142
+ console.log(`Size: ${info.size} bytes`); // number, never undefined
136
143
  console.log(`Modified: ${new Date(info.lastModified)}`);
137
- console.log(`Type: ${info.kind}`); // "file" or "directory"
144
+ } else if (info?.kind === 'directory') {
145
+ console.log('a directory'); // nothing else to report
138
146
  }
139
147
  ```
140
148
 
149
+ A zero-byte file is the file variant with `size: 0`, so test the `kind` rather
150
+ than the truthiness of `size`:
151
+
152
+ ```typescript
153
+ // WRONG - reads an empty file as though it had no size
154
+ if (info.size) { /* ... */ }
155
+
156
+ // RIGHT
157
+ if (info.kind === 'file') { /* info.size may legitimately be 0 */ }
158
+ ```
159
+
141
160
  ### Listing Directories
142
161
 
162
+ `FileInfo` is the same union plus `name` and `path`, so a listing narrows per
163
+ entry exactly as `stats()` does:
164
+
143
165
  ```typescript
144
166
  // List direct children
145
167
  for await (const entry of files.list('/documents')) {
146
- console.log(`${entry.name} (${entry.kind})`);
168
+ const size = entry.kind === 'file' ? `${entry.size} bytes` : '';
169
+ console.log(`${entry.name} (${entry.kind}) ${size}`);
147
170
  }
148
171
 
149
172
  // List recursively
@@ -165,15 +188,29 @@ await files.remove('/old-stuff'); // Recursively deletes directories
165
188
 
166
189
  ```typescript
167
190
  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 }
191
+ FilesApi, // Core interface for backends
192
+ FileStats, // Metadata from stats(): FileEntryStats | DirectoryEntryStats
193
+ FileEntryStats, // { kind: "file", size: number, lastModified: number }
194
+ DirectoryEntryStats, // { kind: "directory" }
195
+ FileInfo, // Metadata from list(): FileEntryInfo | DirectoryEntryInfo
196
+ FileEntryInfo, // FileEntryStats & { name, path }
197
+ DirectoryEntryInfo, // DirectoryEntryStats & { name, path }
198
+ FileEntryLocation, // { name: string, path: string }
199
+ FileKind, // "file" | "directory"
200
+ ReadOptions, // { start?: number, length?: number, signal?: AbortSignal }
201
+ ListOptions, // { recursive?: boolean }
174
202
  } from '@statewalker/webrun-files';
175
203
  ```
176
204
 
205
+ ### Implementing a backend
206
+
207
+ An implementation must return **exactly** one variant: a file with both numbers
208
+ present, a directory with nothing but its `kind`. An implementation that knows a
209
+ directory's modification time drops it rather than offering a value that would
210
+ 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.
213
+
177
214
  ## License
178
215
 
179
216
  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,6 @@ 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
-
92
91
  //#endregion
93
92
  //#region src/path-utils.ts
94
93
  /**
@@ -141,7 +140,6 @@ function extname(path) {
141
140
  if (dotIndex <= 0) return "";
142
141
  return name.substring(dotIndex);
143
142
  }
144
-
145
143
  //#endregion
146
144
  exports.basename = basename;
147
145
  exports.dirname = dirname;
@@ -154,4 +152,4 @@ exports.readRange = readRange;
154
152
  exports.readText = readText;
155
153
  exports.tryReadFile = tryReadFile;
156
154
  exports.tryReadText = tryReadText;
157
- exports.writeText = writeText;
155
+ 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,6 @@ 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
-
91
90
  //#endregion
92
91
  //#region src/path-utils.ts
93
92
  /**
@@ -140,6 +139,5 @@ function extname(path) {
140
139
  if (dotIndex <= 0) return "";
141
140
  return name.substring(dotIndex);
142
141
  }
143
-
144
142
  //#endregion
145
- export { basename, dirname, extname, joinPath, normalizePath, readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText };
143
+ export { basename, dirname, extname, joinPath, normalizePath, readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText };
package/dist/index.d.ts CHANGED
@@ -14,5 +14,5 @@
14
14
  */
15
15
  export { readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText, } from "./file-utils.js";
16
16
  export { basename, dirname, extname, joinPath, normalizePath, } from "./path-utils.js";
17
- export type { FileInfo, FileKind, FileStats, FilesApi, ListOptions, ReadOptions, } from "./types.js";
17
+ export type { DirectoryEntryInfo, DirectoryEntryStats, FileEntryInfo, FileEntryLocation, FileEntryStats, FileInfo, FileKind, FileStats, FilesApi, ListOptions, ReadOptions, } from "./types.js";
18
18
  //# 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;;;;;;;;;;;;;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"}
package/dist/types.d.ts CHANGED
@@ -29,25 +29,68 @@ export interface ListOptions {
29
29
  recursive?: boolean;
30
30
  }
31
31
  /**
32
- * File or directory metadata returned by stats().
33
- * Simplified type without path information.
32
+ * Metadata of a regular file.
33
+ *
34
+ * `size` and `lastModified` are REQUIRED. Every storage that can hold a file
35
+ * can say how large it is and when it last changed; an implementation that
36
+ * cannot is claiming less than it must, and the omission must surface as a
37
+ * failure rather than as an `undefined` a consumer has to defend against.
34
38
  */
35
- export interface FileStats {
36
- kind: FileKind;
37
- size?: number;
38
- lastModified?: number;
39
+ export interface FileEntryStats {
40
+ kind: "file";
41
+ /** Length in bytes. A zero-byte file is `0`, never `undefined`. */
42
+ size: number;
43
+ /** Modification time as milliseconds since the epoch. */
44
+ lastModified: number;
39
45
  }
40
46
  /**
41
- * File or directory information returned by list().
42
- * Includes name and path for directory traversal.
47
+ * Metadata of a directory.
48
+ *
49
+ * Deliberately carries nothing else. A directory has no size, and a
50
+ * modification time is available on some storages (memory, a local
51
+ * filesystem) and unavailable on others (an object store, where a directory
52
+ * is a common prefix that exists only as an artefact of key naming). Neither
53
+ * value may be relied upon, so neither is part of the contract: an
54
+ * implementation that happens to know one drops it here instead of leaking a
55
+ * value consumers would have to treat as optional anyway.
43
56
  */
44
- export interface FileInfo {
57
+ export interface DirectoryEntryStats {
58
+ kind: "directory";
59
+ }
60
+ /**
61
+ * File or directory metadata returned by `stats()`.
62
+ *
63
+ * A DISCRIMINATED UNION on `kind`, not a single shape with optional fields.
64
+ * The optionality this replaces never expressed what a given implementation
65
+ * could report — it expressed a property of the entry kind, collapsed into
66
+ * one type because `kind` was an ordinary field. Narrow on `kind` and the
67
+ * fields that exist for that kind are then known to be present:
68
+ *
69
+ * @example
70
+ * const stats = await api.stats("/some/path");
71
+ * if (stats?.kind === "file") {
72
+ * console.log(stats.size, stats.lastModified); // both `number`
73
+ * }
74
+ */
75
+ export type FileStats = FileEntryStats | DirectoryEntryStats;
76
+ /** Name and path carried by every entry a `list()` yields. */
77
+ export interface FileEntryLocation {
45
78
  name: string;
46
79
  path: string;
47
- kind: FileKind;
48
- size?: number;
49
- lastModified?: number;
50
80
  }
81
+ /** A file yielded by `list()`. */
82
+ export interface FileEntryInfo extends FileEntryStats, FileEntryLocation {
83
+ }
84
+ /** A directory yielded by `list()`. */
85
+ export interface DirectoryEntryInfo extends DirectoryEntryStats, FileEntryLocation {
86
+ }
87
+ /**
88
+ * File or directory information returned by `list()`.
89
+ *
90
+ * The same union as {@link FileStats}, plus `name` and `path`, so a listing
91
+ * consumer narrows per entry exactly as a `stats()` consumer does.
92
+ */
93
+ export type FileInfo = FileEntryInfo | DirectoryEntryInfo;
51
94
  /**
52
95
  * Cross-platform filesystem abstraction interface.
53
96
  *
@@ -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;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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-files",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilesApi interface and utilities for cross-platform filesystem abstraction",
@@ -29,10 +29,10 @@
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
36
  },
37
37
  "sideEffects": false,
38
38
  "publishConfig": {
package/src/index.ts CHANGED
@@ -33,6 +33,11 @@ export {
33
33
  } from "./path-utils.js";
34
34
  // Core types
35
35
  export type {
36
+ DirectoryEntryInfo,
37
+ DirectoryEntryStats,
38
+ FileEntryInfo,
39
+ FileEntryLocation,
40
+ FileEntryStats,
36
41
  FileInfo,
37
42
  FileKind,
38
43
  FileStats,
package/src/types.ts CHANGED
@@ -33,27 +33,73 @@ export interface ListOptions {
33
33
  }
34
34
 
35
35
  /**
36
- * File or directory metadata returned by stats().
37
- * Simplified type without path information.
36
+ * Metadata of a regular file.
37
+ *
38
+ * `size` and `lastModified` are REQUIRED. Every storage that can hold a file
39
+ * can say how large it is and when it last changed; an implementation that
40
+ * cannot is claiming less than it must, and the omission must surface as a
41
+ * failure rather than as an `undefined` a consumer has to defend against.
42
+ */
43
+ export interface FileEntryStats {
44
+ kind: "file";
45
+ /** Length in bytes. A zero-byte file is `0`, never `undefined`. */
46
+ size: number;
47
+ /** Modification time as milliseconds since the epoch. */
48
+ lastModified: number;
49
+ }
50
+
51
+ /**
52
+ * Metadata of a directory.
53
+ *
54
+ * Deliberately carries nothing else. A directory has no size, and a
55
+ * modification time is available on some storages (memory, a local
56
+ * filesystem) and unavailable on others (an object store, where a directory
57
+ * is a common prefix that exists only as an artefact of key naming). Neither
58
+ * value may be relied upon, so neither is part of the contract: an
59
+ * implementation that happens to know one drops it here instead of leaking a
60
+ * value consumers would have to treat as optional anyway.
38
61
  */
39
- export interface FileStats {
40
- kind: FileKind;
41
- size?: number;
42
- lastModified?: number;
62
+ export interface DirectoryEntryStats {
63
+ kind: "directory";
43
64
  }
44
65
 
45
66
  /**
46
- * File or directory information returned by list().
47
- * Includes name and path for directory traversal.
67
+ * File or directory metadata returned by `stats()`.
68
+ *
69
+ * A DISCRIMINATED UNION on `kind`, not a single shape with optional fields.
70
+ * The optionality this replaces never expressed what a given implementation
71
+ * could report — it expressed a property of the entry kind, collapsed into
72
+ * one type because `kind` was an ordinary field. Narrow on `kind` and the
73
+ * fields that exist for that kind are then known to be present:
74
+ *
75
+ * @example
76
+ * const stats = await api.stats("/some/path");
77
+ * if (stats?.kind === "file") {
78
+ * console.log(stats.size, stats.lastModified); // both `number`
79
+ * }
48
80
  */
49
- export interface FileInfo {
81
+ export type FileStats = FileEntryStats | DirectoryEntryStats;
82
+
83
+ /** Name and path carried by every entry a `list()` yields. */
84
+ export interface FileEntryLocation {
50
85
  name: string;
51
86
  path: string;
52
- kind: FileKind;
53
- size?: number;
54
- lastModified?: number;
55
87
  }
56
88
 
89
+ /** A file yielded by `list()`. */
90
+ export interface FileEntryInfo extends FileEntryStats, FileEntryLocation {}
91
+
92
+ /** A directory yielded by `list()`. */
93
+ export interface DirectoryEntryInfo extends DirectoryEntryStats, FileEntryLocation {}
94
+
95
+ /**
96
+ * File or directory information returned by `list()`.
97
+ *
98
+ * The same union as {@link FileStats}, plus `name` and `path`, so a listing
99
+ * consumer narrows per entry exactly as a `stats()` consumer does.
100
+ */
101
+ export type FileInfo = FileEntryInfo | DirectoryEntryInfo;
102
+
57
103
  /**
58
104
  * Cross-platform filesystem abstraction interface.
59
105
  *