@statewalker/webrun-files 0.10.5 → 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 +84 -1
- package/dist/cjs/index.cjs +43 -0
- package/dist/esm/index.js +42 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/move-utils.d.ts +38 -0
- package/dist/move-utils.d.ts.map +1 -0
- package/dist/types.d.ts +72 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/move-utils.ts +55 -0
- package/src/types.ts +76 -2
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
|
|
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
|
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -210,6 +210,47 @@ var PathHeap = class {
|
|
|
210
210
|
return comparePaths(this.#key(a), this.#key(b)) < 0;
|
|
211
211
|
}
|
|
212
212
|
};
|
|
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
|
+
}
|
|
213
254
|
// src/path-utils.ts
|
|
214
255
|
/**
|
|
215
256
|
* Path manipulation utilities for virtual filesystem paths.
|
|
@@ -261,6 +302,7 @@ function extname(path) {
|
|
|
261
302
|
if (dotIndex <= 0) return "";
|
|
262
303
|
return name.substring(dotIndex);
|
|
263
304
|
}
|
|
305
|
+
exports.MoveIncompleteError = MoveIncompleteError;
|
|
264
306
|
exports.basename = basename;
|
|
265
307
|
exports.comparePaths = comparePaths;
|
|
266
308
|
exports.dirname = dirname;
|
|
@@ -273,6 +315,7 @@ exports.readAt = readAt;
|
|
|
273
315
|
exports.readFile = readFile;
|
|
274
316
|
exports.readRange = readRange;
|
|
275
317
|
exports.readText = readText;
|
|
318
|
+
exports.removeMovedSource = removeMovedSource;
|
|
276
319
|
exports.tryReadFile = tryReadFile;
|
|
277
320
|
exports.tryReadText = tryReadText;
|
|
278
321
|
exports.writeText = writeText;
|
package/dist/esm/index.js
CHANGED
|
@@ -209,6 +209,47 @@ var PathHeap = class {
|
|
|
209
209
|
return comparePaths(this.#key(a), this.#key(b)) < 0;
|
|
210
210
|
}
|
|
211
211
|
};
|
|
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
|
+
}
|
|
212
253
|
// src/path-utils.ts
|
|
213
254
|
/**
|
|
214
255
|
* Path manipulation utilities for virtual filesystem paths.
|
|
@@ -260,4 +301,4 @@ function extname(path) {
|
|
|
260
301
|
if (dotIndex <= 0) return "";
|
|
261
302
|
return name.substring(dotIndex);
|
|
262
303
|
}
|
|
263
|
-
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
/**
|
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;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
|
|
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
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
|
|
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
|
|