@statewalker/webrun-files 0.10.4 → 0.11.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
@@ -107,6 +107,86 @@ for (;;) {
107
107
  }
108
108
  ```
109
109
 
110
+ ### Take a lock: create a file only if it is absent
111
+
112
+ ```typescript
113
+ const lock = '/repo/.git/index.lock';
114
+ if (!files.createExclusive) throw new Error('this storage cannot hold a lock');
115
+
116
+ if (!(await files.createExclusive(lock, [new TextEncoder().encode(String(Date.now()))]))) {
117
+ throw new Error('index.lock exists: another writer holds the lock');
118
+ }
119
+ try {
120
+ await files.write('/repo/.git/index.new', newIndex);
121
+ await files.move('/repo/.git/index.new', '/repo/.git/index');
122
+ } finally {
123
+ await files.remove(lock);
124
+ }
125
+ ```
126
+
127
+ `createExclusive(path, content)` creates the file and returns `true` only if nothing exists at
128
+ the path; of several concurrent calls for one path at most one wins (none, if the winner's source
129
+ fails). It returns `false`, writing no file, for an existing file, directory or link. Parent
130
+ directories are created, and may stay created even when the call returns `false`; a source that
131
+ fails part way leaves no file and rethrows. Checking `exists()` and then calling `write()` cannot
132
+ do this: another process, tab or connection can create the file between the two calls.
133
+
134
+ Only the creation is atomic, not the content. On some backends (node) the file is visible while
135
+ its content is written, so a reader can see it empty or partial until the call completes. When
136
+ readers must see the whole content at once, create a temporary file and `move` it into place.
137
+
138
+ The method is optional, because not every storage can create atomically. Feature-detect it as
139
+ above and decide what to do without it: refuse, or fall back to check-then-write where a single
140
+ writer is guaranteed anyway. How far the exclusion reaches is the implementation's:
141
+
142
+ | Package | Mechanism | Excludes |
143
+ | --- | --- | --- |
144
+ | `webrun-files-mem` | synchronous check and reservation | callers sharing the instance |
145
+ | `webrun-files-node` | `open(path, "wx")` (`O_CREAT \| O_EXCL`) | every process on the filesystem |
146
+ | `webrun-files-browser` | Web Lock around check and create | every tab and worker of the origin |
147
+ | `webrun-files-sqlite` | `INSERT ... ON CONFLICT DO NOTHING` | every connection to the database |
148
+ | `webrun-files-s3` | `PutObject` with `If-None-Match: *` | every client of the bucket |
149
+ | `webrun-files-composite` | the owning backend's method | as far as that backend's does |
150
+
151
+ `webrun-files-http` and `webrun-files-rpc` do not offer it yet: neither protocol has a
152
+ conditional create.
153
+
154
+ ### Replace a file atomically: write a temporary, then `move` it
155
+
156
+ ```typescript
157
+ await files.write('/repo/.git/index.lock', newIndex);
158
+ await files.move('/repo/.git/index.lock', '/repo/.git/index'); // replaces the old index
159
+ ```
160
+
161
+ `move(source, target)` replaces an existing file at the target and creates the target's parent
162
+ directories. It returns `false` when the move did not happen: the source is missing, or the
163
+ backend refused the target. Source and target are then unchanged, though parent directories
164
+ created for the target (node, browser) may be left. Moving onto an existing directory differs
165
+ between backends: node, mem, the browser and `cow` follow `rename(2)` (a directory replaces an empty directory; anything else returns
166
+ `false`), while the sqlite backends replace the target's whole subtree. Portable code does not
167
+ move onto directories.
168
+
169
+ Where the storage allows it, the move is atomic: readers see the old file or the new one, never
170
+ a missing or partial target, and a failed move changes nothing.
171
+
172
+ | Package | Mechanism | Atomic |
173
+ | --- | --- | --- |
174
+ | `webrun-files-node` | `fs.rename` | yes |
175
+ | `webrun-files-mem` | one synchronous re-keying of the entries | yes |
176
+ | `webrun-files-sqlite` | one transaction renaming rows by prefix | yes |
177
+ | `webrun-files-browser` | native `FileSystemHandle.move()` (Chromium, OPFS) | yes |
178
+ | `webrun-files-browser` | copy, then delete, without native `move()` | no |
179
+ | `webrun-files-s3` | `CopyObject`, then `DeleteObject` | no |
180
+ | `webrun-files-composite` | the backend's `move` within a mount; copy, then delete, across mounts | as the backend; no across mounts |
181
+ | `webrun-files-composite` `cow` | copy into the writable layer, then remove or hide the source | no |
182
+ | `webrun-files-http`, `webrun-files-rpc` | the server's backend | as the server's backend |
183
+
184
+ A copy-then-delete move that copied but cannot remove its source retries the removal once, then
185
+ throws a `MoveIncompleteError` (exported here, with `source` and `target`): the target already
186
+ holds the new content and the source still exists. Backends built on copy-then-delete call
187
+ `removeMovedSource(files, source, target)` to get this behaviour. The http and rpc clients rebuild
188
+ a server-side `MoveIncompleteError` as one.
189
+
110
190
  ### Paths
111
191
 
112
192
  ```typescript
@@ -186,8 +266,11 @@ Two consequences:
186
266
  - Return exactly one metadata variant: a file with both numbers, a directory with only `kind`.
187
267
  - List in the order above and honour `after` exactly: the entries after a path are the same, in
188
268
  the same order, as the matching suffix of the full listing.
269
+ - Define `createExclusive` only if the storage can keep its promise, and document how far the
270
+ exclusion reaches. A wrapper defines it only when what it wraps does, so feature detection on
271
+ the wrapper stays truthful.
189
272
 
190
- The shared suites in `@statewalker/webrun-files-tests` (a private workspace package) check both at
273
+ The shared suites in `@statewalker/webrun-files-tests` (a private workspace package) check these at
191
274
  runtime. `listInPathOrder` gives both properties to a backend that can read one directory at a
192
275
  time.
193
276
 
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- //#region src/file-utils.ts
2
+ // src/file-utils.ts
3
3
  /**
4
4
  * Collect async iterable chunks into a single Uint8Array.
5
5
  */
@@ -88,8 +88,7 @@ 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
- //#endregion
92
- //#region src/list-order.ts
91
+ // src/list-order.ts
93
92
  /**
94
93
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
95
94
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -211,8 +210,48 @@ var PathHeap = class {
211
210
  return comparePaths(this.#key(a), this.#key(b)) < 0;
212
211
  }
213
212
  };
214
- //#endregion
215
- //#region src/path-utils.ts
213
+ // src/move-utils.ts
214
+ /**
215
+ * Thrown by a `move` done as copy-then-delete when the copy succeeded but the source
216
+ * could not be removed, even on a second attempt. The move is half done: the target
217
+ * already holds the moved content (an existing target was replaced), and the source
218
+ * still exists. A `false` would wrongly say that nothing happened, so this is thrown
219
+ * instead.
220
+ *
221
+ * @example
222
+ * try {
223
+ * await files.move("/repo/index.lock", "/repo/index");
224
+ * } catch (error) {
225
+ * if (error instanceof MoveIncompleteError) await files.remove(error.source);
226
+ * else throw error;
227
+ * }
228
+ */
229
+ var MoveIncompleteError = class extends Error {
230
+ source;
231
+ target;
232
+ name = "MoveIncompleteError";
233
+ constructor(source, target, options) {
234
+ super(`move ${source} -> ${target}: the target was replaced, but the source could not be removed`, options);
235
+ this.source = source;
236
+ this.target = target;
237
+ }
238
+ };
239
+ /**
240
+ * The second half of a copy-then-delete `move`: remove `source` after it has been copied
241
+ * to `target`. A failed removal (an error, or `false` while the source still exists) is
242
+ * retried once; should that fail too, {@link MoveIncompleteError} is thrown, carrying the
243
+ * last error as its cause. A source that is already gone counts as removed.
244
+ */
245
+ async function removeMovedSource(files, source, target) {
246
+ let cause;
247
+ for (let attempt = 0; attempt < 2; attempt++) try {
248
+ if (await files.remove(source) || !await files.exists(source)) return;
249
+ } catch (error) {
250
+ cause = error;
251
+ }
252
+ throw new MoveIncompleteError(source, target, { cause });
253
+ }
254
+ // src/path-utils.ts
216
255
  /**
217
256
  * Path manipulation utilities for virtual filesystem paths.
218
257
  * All paths use forward slashes and start with "/".
@@ -263,7 +302,7 @@ function extname(path) {
263
302
  if (dotIndex <= 0) return "";
264
303
  return name.substring(dotIndex);
265
304
  }
266
- //#endregion
305
+ exports.MoveIncompleteError = MoveIncompleteError;
267
306
  exports.basename = basename;
268
307
  exports.comparePaths = comparePaths;
269
308
  exports.dirname = dirname;
@@ -276,6 +315,7 @@ exports.readAt = readAt;
276
315
  exports.readFile = readFile;
277
316
  exports.readRange = readRange;
278
317
  exports.readText = readText;
318
+ exports.removeMovedSource = removeMovedSource;
279
319
  exports.tryReadFile = tryReadFile;
280
320
  exports.tryReadText = tryReadText;
281
321
  exports.writeText = writeText;
package/dist/esm/index.js CHANGED
@@ -1,4 +1,4 @@
1
- //#region src/file-utils.ts
1
+ // src/file-utils.ts
2
2
  /**
3
3
  * Collect async iterable chunks into a single Uint8Array.
4
4
  */
@@ -87,8 +87,7 @@ 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
- //#endregion
91
- //#region src/list-order.ts
90
+ // src/list-order.ts
92
91
  /**
93
92
  * The order every `FilesApi.list()` yields: paths compared by Unicode code
94
93
  * point, which is the same as comparing their UTF-8 bytes — the order of
@@ -210,8 +209,48 @@ var PathHeap = class {
210
209
  return comparePaths(this.#key(a), this.#key(b)) < 0;
211
210
  }
212
211
  };
213
- //#endregion
214
- //#region src/path-utils.ts
212
+ // src/move-utils.ts
213
+ /**
214
+ * Thrown by a `move` done as copy-then-delete when the copy succeeded but the source
215
+ * could not be removed, even on a second attempt. The move is half done: the target
216
+ * already holds the moved content (an existing target was replaced), and the source
217
+ * still exists. A `false` would wrongly say that nothing happened, so this is thrown
218
+ * instead.
219
+ *
220
+ * @example
221
+ * try {
222
+ * await files.move("/repo/index.lock", "/repo/index");
223
+ * } catch (error) {
224
+ * if (error instanceof MoveIncompleteError) await files.remove(error.source);
225
+ * else throw error;
226
+ * }
227
+ */
228
+ var MoveIncompleteError = class extends Error {
229
+ source;
230
+ target;
231
+ name = "MoveIncompleteError";
232
+ constructor(source, target, options) {
233
+ super(`move ${source} -> ${target}: the target was replaced, but the source could not be removed`, options);
234
+ this.source = source;
235
+ this.target = target;
236
+ }
237
+ };
238
+ /**
239
+ * The second half of a copy-then-delete `move`: remove `source` after it has been copied
240
+ * to `target`. A failed removal (an error, or `false` while the source still exists) is
241
+ * retried once; should that fail too, {@link MoveIncompleteError} is thrown, carrying the
242
+ * last error as its cause. A source that is already gone counts as removed.
243
+ */
244
+ async function removeMovedSource(files, source, target) {
245
+ let cause;
246
+ for (let attempt = 0; attempt < 2; attempt++) try {
247
+ if (await files.remove(source) || !await files.exists(source)) return;
248
+ } catch (error) {
249
+ cause = error;
250
+ }
251
+ throw new MoveIncompleteError(source, target, { cause });
252
+ }
253
+ // src/path-utils.ts
215
254
  /**
216
255
  * Path manipulation utilities for virtual filesystem paths.
217
256
  * All paths use forward slashes and start with "/".
@@ -262,5 +301,4 @@ function extname(path) {
262
301
  if (dotIndex <= 0) return "";
263
302
  return name.substring(dotIndex);
264
303
  }
265
- //#endregion
266
- export { basename, comparePaths, dirname, extname, joinPath, listInPathOrder, mergeInPathOrder, normalizePath, readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText };
304
+ export { MoveIncompleteError, basename, comparePaths, dirname, extname, joinPath, listInPathOrder, mergeInPathOrder, normalizePath, readAt, readFile, readRange, readText, removeMovedSource, tryReadFile, tryReadText, writeText };
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  export { readAt, readFile, readRange, readText, tryReadFile, tryReadText, writeText, } from "./file-utils.js";
19
19
  export { comparePaths, listInPathOrder, mergeInPathOrder } from "./list-order.js";
20
+ export { MoveIncompleteError, removeMovedSource } from "./move-utils.js";
20
21
  export { basename, dirname, extname, joinPath, normalizePath, } from "./path-utils.js";
21
22
  export type { DirectoryEntryInfo, DirectoryEntryStats, FileEntryInfo, FileEntryLocation, FileEntryStats, FileInfo, FileKind, FileStats, FilesApi, ListOptions, ReadOptions, } from "./types.js";
22
23
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
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"}
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,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzE,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,38 @@
1
+ import type { FilesApi } from "./types.js";
2
+ /**
3
+ * Thrown by a `move` done as copy-then-delete when the copy succeeded but the source
4
+ * could not be removed, even on a second attempt. The move is half done: the target
5
+ * already holds the moved content (an existing target was replaced), and the source
6
+ * still exists. A `false` would wrongly say that nothing happened, so this is thrown
7
+ * instead.
8
+ *
9
+ * @example
10
+ * try {
11
+ * await files.move("/repo/index.lock", "/repo/index");
12
+ * } catch (error) {
13
+ * if (error instanceof MoveIncompleteError) await files.remove(error.source);
14
+ * else throw error;
15
+ * }
16
+ */
17
+ export declare class MoveIncompleteError extends Error {
18
+ /** The path that still exists and could not be removed. */
19
+ readonly source: string;
20
+ /** The path that now holds the moved content. */
21
+ readonly target: string;
22
+ readonly name = "MoveIncompleteError";
23
+ constructor(
24
+ /** The path that still exists and could not be removed. */
25
+ source: string,
26
+ /** The path that now holds the moved content. */
27
+ target: string, options?: {
28
+ cause?: unknown;
29
+ });
30
+ }
31
+ /**
32
+ * The second half of a copy-then-delete `move`: remove `source` after it has been copied
33
+ * to `target`. A failed removal (an error, or `false` while the source still exists) is
34
+ * retried once; should that fail too, {@link MoveIncompleteError} is thrown, carrying the
35
+ * last error as its cause. A source that is already gone counts as removed.
36
+ */
37
+ export declare function removeMovedSource(files: Pick<FilesApi, "remove" | "exists">, source: string, target: string): Promise<void>;
38
+ //# sourceMappingURL=move-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"move-utils.d.ts","sourceRoot":"","sources":["../src/move-utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;;;;;;;;;GAcG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAI1C,2DAA2D;IAC3D,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,iDAAiD;IACjD,QAAQ,CAAC,MAAM,EAAE,MAAM;IANzB,SAAkB,IAAI,yBAAyB;IAE/C;IACE,2DAA2D;IAClD,MAAM,EAAE,MAAM;IACvB,iDAAiD;IACxC,MAAM,EAAE,MAAM,EACvB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAM9B;CACF;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,KAAK,EAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAAC,EAC1C,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAUf"}
package/dist/types.d.ts CHANGED
@@ -117,6 +117,39 @@ export interface FilesApi {
117
117
  * Overwrites existing file content.
118
118
  */
119
119
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
120
+ /**
121
+ * Create `path` with `content` only if nothing exists there, atomically: of two
122
+ * concurrent calls for one path, at most one returns true. Returns false, and writes no
123
+ * file, when the path already exists, whether as a file, a directory or a link.
124
+ * Parent directories are created as needed, and may be left created even when the call
125
+ * returns false. If the content fails part way, the file is removed again and the
126
+ * error is thrown.
127
+ *
128
+ * Only the creation is atomic, not the content: on some backends (node) the file is
129
+ * visible while its content is written, so a reader can see it empty or partial until
130
+ * the call completes. Callers that need readers to see the whole content at once
131
+ * should create a temporary file and `move` it into place.
132
+ *
133
+ * This is the primitive behind lock files (git's `index.lock`, `refs/heads/main.lock`):
134
+ * take the lock by creating the file, release it by removing or renaming it.
135
+ *
136
+ * OPTIONAL. Storages that cannot create atomically leave it out, so code written
137
+ * against an older `FilesApi` keeps compiling and an implementation never claims a
138
+ * guarantee it cannot keep. Callers feature-detect it and choose their own fallback:
139
+ *
140
+ * @example
141
+ * if (!api.createExclusive) throw new Error("this storage cannot hold a lock");
142
+ * const locked = await api.createExclusive("/repo/.git/index.lock", [bytes]);
143
+ * if (!locked) throw new Error("index.lock exists: another process holds the lock");
144
+ *
145
+ * How far the exclusion reaches depends on the implementation, and each one
146
+ * documents it: an in-memory store excludes callers sharing that instance, a local
147
+ * filesystem every process on the machine, the browser every tab and worker of the
148
+ * origin, a database every connection to it, an object store every client of the
149
+ * bucket. Two different `FilesApi` instances over the same storage exclude each other
150
+ * only as far as that reach extends.
151
+ */
152
+ createExclusive?(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<boolean>;
120
153
  /**
121
154
  * Create a directory and all parent directories.
122
155
  * No-op if directory already exists.
@@ -146,8 +179,45 @@ export interface FilesApi {
146
179
  */
147
180
  remove(path: string): Promise<boolean>;
148
181
  /**
149
- * Move/rename a file or directory.
150
- * Returns true on success, false if source doesn't exist.
182
+ * Move/rename a file or directory, with everything under it, creating the target's
183
+ * parent directories as needed. Returns true on success, and false when the move did
184
+ * not happen: the source does not exist, or the backend refused the target (below).
185
+ * Source and target are then unchanged, but parent directories created for the target
186
+ * may be left behind even when `move` returns false (node and the browser create them
187
+ * before moving).
188
+ *
189
+ * @example
190
+ * // Publish a new index: write a lock file, then move it over the real one.
191
+ * await files.write("/repo/.git/index.lock", [bytes]);
192
+ * await files.move("/repo/.git/index.lock", "/repo/.git/index");
193
+ *
194
+ * An existing FILE at the target is replaced. That is the pattern above, and every
195
+ * implementation supports it.
196
+ *
197
+ * An existing DIRECTORY at the target is backend specific. Filesystem-like backends
198
+ * follow POSIX `rename(2)`: node, mem, the browser and `cow` replace an EMPTY directory with
199
+ * a directory, and return false, changing nothing, for a directory onto a non-empty
200
+ * directory, a file onto a directory, a directory onto a file, or a directory into
201
+ * its own subtree. The sqlite backends replace the target's whole subtree, and throw
202
+ * when one path contains the other (Sqlar returns false for a target below a file).
203
+ * Portable callers do not move onto directories.
204
+ *
205
+ * ATOMICITY. Where the backend allows it, a move is atomic: a reader sees the old
206
+ * state or the new one, never the target missing or partial, and a failed move
207
+ * changes nothing. That holds for node (`rename(2)`), mem (one synchronous step),
208
+ * sqlite (one transaction) and the browser where the native `FileSystemHandle.move()`
209
+ * exists (Chromium, OPFS). It does NOT hold where the move is a copy then a delete:
210
+ * s3 (no rename), the browser without native move, a `CompositeFilesApi` move across
211
+ * mounts, the copy-on-write layer (`cow` from webrun-files-composite, which copies into
212
+ * its writable layer, then removes or hides the source), and http/rpc clients, which
213
+ * inherit whatever the server's backend does.
214
+ *
215
+ * PARTIAL FAILURE of a copy-then-delete move: if the copy fails, the error is thrown
216
+ * and the source is kept (the target may hold part of a directory copy; a single file
217
+ * is replaced whole or not at all on the browser). If the copy succeeds but deleting
218
+ * the source fails, the delete is retried once and then a `MoveIncompleteError` is
219
+ * thrown: the target already holds the new content and the source still exists.
220
+ * False is never returned for a half-done move.
151
221
  */
152
222
  move(source: string, target: string): Promise<boolean>;
153
223
  /**
@@ -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;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"}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;IACH,eAAe,CAAC,CACd,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,QAAQ,CAAC,UAAU,CAAC,GAAG,aAAa,CAAC,UAAU,CAAC,GACxD,OAAO,CAAC,OAAO,CAAC,CAAC;IAEpB;;;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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAwCG;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.10.4",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "FilesApi interface and utilities for cross-platform filesystem abstraction",
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export {
28
28
  } from "./file-utils.js";
29
29
  // Listing order
30
30
  export { comparePaths, listInPathOrder, mergeInPathOrder } from "./list-order.js";
31
+ // Moves done as copy-then-delete
32
+ export { MoveIncompleteError, removeMovedSource } from "./move-utils.js";
31
33
  // Path utilities
32
34
  export {
33
35
  basename,
@@ -0,0 +1,55 @@
1
+ import type { FilesApi } from "./types.js";
2
+
3
+ /**
4
+ * Thrown by a `move` done as copy-then-delete when the copy succeeded but the source
5
+ * could not be removed, even on a second attempt. The move is half done: the target
6
+ * already holds the moved content (an existing target was replaced), and the source
7
+ * still exists. A `false` would wrongly say that nothing happened, so this is thrown
8
+ * instead.
9
+ *
10
+ * @example
11
+ * try {
12
+ * await files.move("/repo/index.lock", "/repo/index");
13
+ * } catch (error) {
14
+ * if (error instanceof MoveIncompleteError) await files.remove(error.source);
15
+ * else throw error;
16
+ * }
17
+ */
18
+ export class MoveIncompleteError extends Error {
19
+ override readonly name = "MoveIncompleteError";
20
+
21
+ constructor(
22
+ /** The path that still exists and could not be removed. */
23
+ readonly source: string,
24
+ /** The path that now holds the moved content. */
25
+ readonly target: string,
26
+ options?: { cause?: unknown },
27
+ ) {
28
+ super(
29
+ `move ${source} -> ${target}: the target was replaced, but the source could not be removed`,
30
+ options,
31
+ );
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The second half of a copy-then-delete `move`: remove `source` after it has been copied
37
+ * to `target`. A failed removal (an error, or `false` while the source still exists) is
38
+ * retried once; should that fail too, {@link MoveIncompleteError} is thrown, carrying the
39
+ * last error as its cause. A source that is already gone counts as removed.
40
+ */
41
+ export async function removeMovedSource(
42
+ files: Pick<FilesApi, "remove" | "exists">,
43
+ source: string,
44
+ target: string,
45
+ ): Promise<void> {
46
+ let cause: unknown;
47
+ for (let attempt = 0; attempt < 2; attempt++) {
48
+ try {
49
+ if ((await files.remove(source)) || !(await files.exists(source))) return;
50
+ } catch (error) {
51
+ cause = error;
52
+ }
53
+ }
54
+ throw new MoveIncompleteError(source, target, { cause });
55
+ }
package/src/types.ts CHANGED
@@ -128,6 +128,43 @@ export interface FilesApi {
128
128
  */
129
129
  write(path: string, content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>): Promise<void>;
130
130
 
131
+ /**
132
+ * Create `path` with `content` only if nothing exists there, atomically: of two
133
+ * concurrent calls for one path, at most one returns true. Returns false, and writes no
134
+ * file, when the path already exists, whether as a file, a directory or a link.
135
+ * Parent directories are created as needed, and may be left created even when the call
136
+ * returns false. If the content fails part way, the file is removed again and the
137
+ * error is thrown.
138
+ *
139
+ * Only the creation is atomic, not the content: on some backends (node) the file is
140
+ * visible while its content is written, so a reader can see it empty or partial until
141
+ * the call completes. Callers that need readers to see the whole content at once
142
+ * should create a temporary file and `move` it into place.
143
+ *
144
+ * This is the primitive behind lock files (git's `index.lock`, `refs/heads/main.lock`):
145
+ * take the lock by creating the file, release it by removing or renaming it.
146
+ *
147
+ * OPTIONAL. Storages that cannot create atomically leave it out, so code written
148
+ * against an older `FilesApi` keeps compiling and an implementation never claims a
149
+ * guarantee it cannot keep. Callers feature-detect it and choose their own fallback:
150
+ *
151
+ * @example
152
+ * if (!api.createExclusive) throw new Error("this storage cannot hold a lock");
153
+ * const locked = await api.createExclusive("/repo/.git/index.lock", [bytes]);
154
+ * if (!locked) throw new Error("index.lock exists: another process holds the lock");
155
+ *
156
+ * How far the exclusion reaches depends on the implementation, and each one
157
+ * documents it: an in-memory store excludes callers sharing that instance, a local
158
+ * filesystem every process on the machine, the browser every tab and worker of the
159
+ * origin, a database every connection to it, an object store every client of the
160
+ * bucket. Two different `FilesApi` instances over the same storage exclude each other
161
+ * only as far as that reach extends.
162
+ */
163
+ createExclusive?(
164
+ path: string,
165
+ content: Iterable<Uint8Array> | AsyncIterable<Uint8Array>,
166
+ ): Promise<boolean>;
167
+
131
168
  /**
132
169
  * Create a directory and all parent directories.
133
170
  * No-op if directory already exists.
@@ -162,8 +199,45 @@ export interface FilesApi {
162
199
  remove(path: string): Promise<boolean>;
163
200
 
164
201
  /**
165
- * Move/rename a file or directory.
166
- * Returns true on success, false if source doesn't exist.
202
+ * Move/rename a file or directory, with everything under it, creating the target's
203
+ * parent directories as needed. Returns true on success, and false when the move did
204
+ * not happen: the source does not exist, or the backend refused the target (below).
205
+ * Source and target are then unchanged, but parent directories created for the target
206
+ * may be left behind even when `move` returns false (node and the browser create them
207
+ * before moving).
208
+ *
209
+ * @example
210
+ * // Publish a new index: write a lock file, then move it over the real one.
211
+ * await files.write("/repo/.git/index.lock", [bytes]);
212
+ * await files.move("/repo/.git/index.lock", "/repo/.git/index");
213
+ *
214
+ * An existing FILE at the target is replaced. That is the pattern above, and every
215
+ * implementation supports it.
216
+ *
217
+ * An existing DIRECTORY at the target is backend specific. Filesystem-like backends
218
+ * follow POSIX `rename(2)`: node, mem, the browser and `cow` replace an EMPTY directory with
219
+ * a directory, and return false, changing nothing, for a directory onto a non-empty
220
+ * directory, a file onto a directory, a directory onto a file, or a directory into
221
+ * its own subtree. The sqlite backends replace the target's whole subtree, and throw
222
+ * when one path contains the other (Sqlar returns false for a target below a file).
223
+ * Portable callers do not move onto directories.
224
+ *
225
+ * ATOMICITY. Where the backend allows it, a move is atomic: a reader sees the old
226
+ * state or the new one, never the target missing or partial, and a failed move
227
+ * changes nothing. That holds for node (`rename(2)`), mem (one synchronous step),
228
+ * sqlite (one transaction) and the browser where the native `FileSystemHandle.move()`
229
+ * exists (Chromium, OPFS). It does NOT hold where the move is a copy then a delete:
230
+ * s3 (no rename), the browser without native move, a `CompositeFilesApi` move across
231
+ * mounts, the copy-on-write layer (`cow` from webrun-files-composite, which copies into
232
+ * its writable layer, then removes or hides the source), and http/rpc clients, which
233
+ * inherit whatever the server's backend does.
234
+ *
235
+ * PARTIAL FAILURE of a copy-then-delete move: if the copy fails, the error is thrown
236
+ * and the source is kept (the target may hold part of a directory copy; a single file
237
+ * is replaced whole or not at all on the browser). If the copy succeeds but deleting
238
+ * the source fails, the delete is retried once and then a `MoveIncompleteError` is
239
+ * thrown: the target already holds the new content and the source still exists.
240
+ * False is never returned for a half-done move.
167
241
  */
168
242
  move(source: string, target: string): Promise<boolean>;
169
243