@stonyx/utils 0.2.3-alpha.25 → 0.2.3-alpha.27
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 +5 -1
- package/dist/file.d.ts +23 -6
- package/dist/file.js +48 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,7 +79,11 @@ Updates a file atomically by writing to a sibling swap file first, then renaming
|
|
|
79
79
|
|
|
80
80
|
* `options.json` — boolean, serialize as JSON.
|
|
81
81
|
|
|
82
|
-
**Concurrency: last writer wins.** Concurrent `updateFile` calls on the same path are safe — each uses a swap file unique to its process and call, so neither caller can
|
|
82
|
+
**Concurrency: last writer wins.** Concurrent `updateFile` calls on the same path are safe — each uses a swap file unique to its process and call, so neither caller's swap file can collide with the other's. The rename-time `ENOENT` and the silent cross-caller byte clobber of [#44](https://github.com/abofs/stonyx-utils/issues/44) are both gone. (`updateFile` still throws `ENOENT` from its own precondition when the target does not exist — that is the documented contract above, and it is unrelated to concurrency.) Calls are not serialized: the surviving value is whichever caller renames last.
|
|
83
|
+
|
|
84
|
+
**File mode is preserved; other inode metadata is not.** The swap file is created with the target's permission bits and `chmod`ed to them before the rename, so a `0600` file stays `0600`. ACLs, extended attributes, hard links and ownership are *not* carried across — the target necessarily gets a new inode.
|
|
85
|
+
|
|
86
|
+
**Swap files after abnormal termination.** Cleanup runs on every failure path `updateFile` controls, but a process killed between the write and the rename leaves a `<path>.temp-<pid>-<token>` sibling behind, and no module ships a sweeper. This is a deliberate trade against the previous whole-second swap name, which self-reclaimed by being reused — reuse is exactly the collision that caused #44, so uniqueness has to win. The cost is that abandoned swap files now accumulate rather than overwriting one another. Consumers that persist into a directory they scan, sync or commit should sweep or ignore `*.temp-*` siblings of their targets; an app that runs `git add` over its data directory will otherwise commit one.
|
|
83
87
|
|
|
84
88
|
#### `copyFile(sourcePath, targetPath, options={})`
|
|
85
89
|
|
package/dist/file.d.ts
CHANGED
|
@@ -28,16 +28,33 @@ export declare function createFile(filePath: string, data: string | Record<strin
|
|
|
28
28
|
*
|
|
29
29
|
* Writes to a swap file that is a **sibling** of the target — so `rename` stays
|
|
30
30
|
* on one filesystem and therefore stays atomic — then renames it over the
|
|
31
|
-
* target. The swap name carries `process.pid` and a
|
|
32
|
-
* timestamp: a whole-second token collided between concurrent callers,
|
|
33
|
-
* made one caller throw `ENOENT` and the other silently persist bytes it
|
|
34
|
-
* not written (abofs/stonyx-utils#44).
|
|
31
|
+
* target. The swap name carries `process.pid` and a short random token rather
|
|
32
|
+
* than a timestamp: a whole-second token collided between concurrent callers,
|
|
33
|
+
* which made one caller throw `ENOENT` and the other silently persist bytes it
|
|
34
|
+
* had not written (abofs/stonyx-utils#44).
|
|
35
35
|
*
|
|
36
36
|
* Concurrency contract: **last writer wins**. Unique swap names remove the
|
|
37
37
|
* `ENOENT` and the byte-level clobber, but overlapping calls on one path still
|
|
38
|
-
* race on which value lands last. `updateFile` does not
|
|
39
|
-
* needing a
|
|
38
|
+
* race on which value lands last. `updateFile` does not serialize, and callers
|
|
39
|
+
* needing a serialization guarantee must supply their own — an in-process queue
|
|
40
40
|
* would not provide one across processes anyway.
|
|
41
|
+
*
|
|
42
|
+
* The target's mode is read before the write and reapplied to the swap file, so
|
|
43
|
+
* the rename does not widen a deliberately restrictive file (a `0600` database
|
|
44
|
+
* would otherwise become `0666 & ~umask`). Other inode-bound metadata — ACLs,
|
|
45
|
+
* extended attributes, hard links, ownership — is *not* carried across; the
|
|
46
|
+
* target gets a new inode by construction.
|
|
47
|
+
*
|
|
48
|
+
* `rename` is atomic against concurrent *readers*, not against power loss:
|
|
49
|
+
* there is no `fsync`, so this is namespace atomicity, not crash durability.
|
|
50
|
+
* If the target is a symlink it is replaced by a regular file — the link's
|
|
51
|
+
* destination is never written through.
|
|
52
|
+
*
|
|
53
|
+
* Swap files are cleaned up on every failure path this function controls, but
|
|
54
|
+
* a process killed between the write and the rename leaves a permanently
|
|
55
|
+
* distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
|
|
56
|
+
* sweeper ships with this module; `*.temp-*` siblings of a target are safe for
|
|
57
|
+
* a consumer to delete.
|
|
41
58
|
*/
|
|
42
59
|
export declare function updateFile(filePath: string, data: string | Record<string, unknown>, options?: FileOptions): Promise<void>;
|
|
43
60
|
interface CopyFileOptions {
|
package/dist/file.js
CHANGED
|
@@ -22,33 +22,66 @@ export async function createFile(filePath, data, options = {}) {
|
|
|
22
22
|
*
|
|
23
23
|
* Writes to a swap file that is a **sibling** of the target — so `rename` stays
|
|
24
24
|
* on one filesystem and therefore stays atomic — then renames it over the
|
|
25
|
-
* target. The swap name carries `process.pid` and a
|
|
26
|
-
* timestamp: a whole-second token collided between concurrent callers,
|
|
27
|
-
* made one caller throw `ENOENT` and the other silently persist bytes it
|
|
28
|
-
* not written (abofs/stonyx-utils#44).
|
|
25
|
+
* target. The swap name carries `process.pid` and a short random token rather
|
|
26
|
+
* than a timestamp: a whole-second token collided between concurrent callers,
|
|
27
|
+
* which made one caller throw `ENOENT` and the other silently persist bytes it
|
|
28
|
+
* had not written (abofs/stonyx-utils#44).
|
|
29
29
|
*
|
|
30
30
|
* Concurrency contract: **last writer wins**. Unique swap names remove the
|
|
31
31
|
* `ENOENT` and the byte-level clobber, but overlapping calls on one path still
|
|
32
|
-
* race on which value lands last. `updateFile` does not
|
|
33
|
-
* needing a
|
|
32
|
+
* race on which value lands last. `updateFile` does not serialize, and callers
|
|
33
|
+
* needing a serialization guarantee must supply their own — an in-process queue
|
|
34
34
|
* would not provide one across processes anyway.
|
|
35
|
+
*
|
|
36
|
+
* The target's mode is read before the write and reapplied to the swap file, so
|
|
37
|
+
* the rename does not widen a deliberately restrictive file (a `0600` database
|
|
38
|
+
* would otherwise become `0666 & ~umask`). Other inode-bound metadata — ACLs,
|
|
39
|
+
* extended attributes, hard links, ownership — is *not* carried across; the
|
|
40
|
+
* target gets a new inode by construction.
|
|
41
|
+
*
|
|
42
|
+
* `rename` is atomic against concurrent *readers*, not against power loss:
|
|
43
|
+
* there is no `fsync`, so this is namespace atomicity, not crash durability.
|
|
44
|
+
* If the target is a symlink it is replaced by a regular file — the link's
|
|
45
|
+
* destination is never written through.
|
|
46
|
+
*
|
|
47
|
+
* Swap files are cleaned up on every failure path this function controls, but
|
|
48
|
+
* a process killed between the write and the rename leaves a permanently
|
|
49
|
+
* distinct `<path>.temp-<pid>-<token>` sibling that nothing reclaims. No
|
|
50
|
+
* sweeper ships with this module; `*.temp-*` siblings of a target are safe for
|
|
51
|
+
* a consumer to delete.
|
|
35
52
|
*/
|
|
36
53
|
export async function updateFile(filePath, data, options = {}) {
|
|
37
54
|
try {
|
|
55
|
+
filePath = path.resolve(filePath);
|
|
38
56
|
await fsp.access(filePath);
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
57
|
+
// The swap file becomes the target's inode, so it has to carry the target's
|
|
58
|
+
// permission bits or a routine save silently widens them.
|
|
59
|
+
const { mode } = await fsp.stat(filePath);
|
|
60
|
+
const targetMode = mode & 0o7777;
|
|
61
|
+
// pid + random token, never a timestamp: the swap name is a uniqueness
|
|
62
|
+
// token, and whole seconds cannot discriminate between concurrent callers.
|
|
63
|
+
// Truncated to 8 hex chars deliberately — a full 36-char UUID pushed the
|
|
64
|
+
// name overhead to 48 characters and made ~210-character basenames fail
|
|
65
|
+
// ENAMETOOLONG on a 255-byte NAME_MAX. `wx` below makes any residual
|
|
66
|
+
// collision loud, so the shortened token costs no safety.
|
|
67
|
+
const swapFile = `${filePath}.temp-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
45
68
|
try {
|
|
69
|
+
// `wx` turns any residual name collision into a loud EEXIST rather than a
|
|
70
|
+
// silent overwrite of another caller's swap bytes. `mode` here is masked
|
|
71
|
+
// by the umask, so it only narrows — the chmod below sets the exact bits.
|
|
72
|
+
await fsp.writeFile(swapFile, options.json ? objToJson(data) : String(data), { encoding: 'utf8', flag: 'wx', mode: targetMode });
|
|
73
|
+
await fsp.chmod(swapFile, targetMode);
|
|
46
74
|
await fsp.rename(swapFile, filePath);
|
|
47
75
|
}
|
|
48
|
-
catch (
|
|
76
|
+
catch (swapError) {
|
|
49
77
|
// Leave no orphan behind, and never let the cleanup mask the real error.
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
// EEXIST is the one code that must NOT be cleaned up: it means this call
|
|
79
|
+
// did not create the path, so the file belongs to another writer and
|
|
80
|
+
// unlinking it would reintroduce #44.
|
|
81
|
+
if (!(isNodeError(swapError) && swapError.code === 'EEXIST')) {
|
|
82
|
+
await fsp.unlink(swapFile).catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
throw swapError;
|
|
52
85
|
}
|
|
53
86
|
}
|
|
54
87
|
catch (error) {
|