@zukhruf/fs 0.0.0-stage → 0.3.18

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/dist/atomic-write.d.ts +3 -0
  4. package/dist/atomic-write.d.ts.map +1 -0
  5. package/dist/atomic-write.js +17 -0
  6. package/dist/atomic-write.js.map +1 -0
  7. package/dist/create-exclusive.d.ts +6 -0
  8. package/dist/create-exclusive.d.ts.map +1 -0
  9. package/dist/create-exclusive.js +29 -0
  10. package/dist/create-exclusive.js.map +1 -0
  11. package/dist/draft.d.ts +13 -0
  12. package/dist/draft.d.ts.map +1 -0
  13. package/dist/draft.js +14 -0
  14. package/dist/draft.js.map +1 -0
  15. package/dist/durable-write.d.ts +7 -0
  16. package/dist/durable-write.d.ts.map +1 -0
  17. package/dist/durable-write.js +46 -0
  18. package/dist/durable-write.js.map +1 -0
  19. package/dist/errno.d.ts +2 -0
  20. package/dist/errno.d.ts.map +1 -0
  21. package/dist/errno.js +4 -0
  22. package/dist/errno.js.map +1 -0
  23. package/dist/file-lock.d.ts +31 -0
  24. package/dist/file-lock.d.ts.map +1 -0
  25. package/dist/file-lock.js +102 -0
  26. package/dist/file-lock.js.map +1 -0
  27. package/dist/index.d.ts +11 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +11 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/local-directory.d.ts +7 -0
  32. package/dist/local-directory.d.ts.map +1 -0
  33. package/dist/local-directory.js +75 -0
  34. package/dist/local-directory.js.map +1 -0
  35. package/dist/network-directory-error.d.ts +8 -0
  36. package/dist/network-directory-error.d.ts.map +1 -0
  37. package/dist/network-directory-error.js +13 -0
  38. package/dist/network-directory-error.js.map +1 -0
  39. package/dist/patiently.d.ts +3 -0
  40. package/dist/patiently.d.ts.map +1 -0
  41. package/dist/patiently.js +30 -0
  42. package/dist/patiently.js.map +1 -0
  43. package/dist/replace-file.d.ts +8 -0
  44. package/dist/replace-file.d.ts.map +1 -0
  45. package/dist/replace-file.js +12 -0
  46. package/dist/replace-file.js.map +1 -0
  47. package/dist/safe-file-name.d.ts +17 -0
  48. package/dist/safe-file-name.d.ts.map +1 -0
  49. package/dist/safe-file-name.js +38 -0
  50. package/dist/safe-file-name.js.map +1 -0
  51. package/package.json +48 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ezzabuzaid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # @zukhruf/fs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Helpers that write files, and a lock on a file that the kernel frees when its holder dies. A reader never sees a part of a write. `@zukhruf/mutex` uses them for its lock files, `@zukhruf/fencing` for its fencing counters, and `@zukhruf/election` for the claim and the epoch of each election. `@zukhruf/single-flight` keeps no lock files of its own: its election claims a `FileLock` through `@zukhruf/election`.
4
+
5
+ The words in these documents have one meaning each. See the glossary in [CONTEXT.md](./CONTEXT.md).
6
+
7
+ ## Replace a file in one step
8
+
9
+ A write that changes a file in place can stop halfway. Then a reader sees a part of the old content and a part of the new content. `atomicWrite` fills a draft beside the path, and then moves the draft over the path with one rename. A reader sees the old content or the new content.
10
+
11
+ ```ts
12
+ import { atomicWrite } from '@zukhruf/fs';
13
+
14
+ await atomicWrite('/var/lib/app/queue', 'first\nsecond\n');
15
+ ```
16
+
17
+ ## Make the replacement durable
18
+
19
+ After a rename, the operating system can keep the new content in memory for some time. A power loss in that time can bring back the old content. `durableWrite` replaces the file in one step, like `atomicWrite`. It returns only after the new content and the rename are on the disk:
20
+
21
+ 1. It writes the draft and syncs the draft to the disk.
22
+ 2. It moves the draft over the path.
23
+ 3. It syncs the directory, because the rename is a change of the directory.
24
+
25
+ ```ts
26
+ import { durableWrite } from '@zukhruf/fs';
27
+
28
+ await durableWrite('/var/lib/app/counter', '42');
29
+ ```
30
+
31
+ Use `durableWrite` when a value must never go back after a crash, for example a counter that gives out fencing tokens. A counter that goes back gives the same token two times.
32
+
33
+ On Windows, a process cannot open a directory to sync it. Some file systems refuse a sync of a directory with `EINVAL` or `ENOTSUP`. In these cases, the rename is as durable as that system makes it. Each other error of the sync reaches the caller.
34
+
35
+ ## Create a file only when it is absent
36
+
37
+ `createExclusive` creates the file when no file is at the path, and returns `true`. When a file is there, it changes nothing and returns `false`. It fills a draft first and then links the draft to the path. Thus the file is never empty: a reader of the new file sees all of its content.
38
+
39
+ ```ts
40
+ import { createExclusive } from '@zukhruf/fs';
41
+
42
+ const created = await createExclusive('/var/lib/app/job.lock', 'pid 4242');
43
+ if (!created) console.log('Another process holds the job.');
44
+ ```
45
+
46
+ ## A failed write leaves no draft
47
+
48
+ When a write fails, it removes its draft. Thus failed writes do not fill the directory with drafts. The file at the path keeps its old content.
49
+
50
+ ## Keep room for the name of the draft
51
+
52
+ A draft has the name of the path plus `draftSuffixLength` characters. Most file systems limit a file name to 255 bytes. A caller that makes long file names, for example from user keys, keeps `draftSuffixLength` characters of room. Otherwise the name of the draft is too long, and the write fails with `ENAMETOOLONG`.
53
+
54
+ ## Name a file after any key
55
+
56
+ A caller can keep one file for each key, for example one lock file for each lock key. The caller must make a file name from the key. A key can hold `/`, be `.` or `..`, or be too long for a file name. `safeFileName(key, longestSuffix)` gives a name that is one path segment. The name plus `longestSuffix` more characters fits in 255 characters, the longest file name of most file systems.
57
+
58
+ ```ts
59
+ import { join } from 'node:path';
60
+
61
+ import { createExclusive, draftSuffixLength, safeFileName } from '@zukhruf/fs';
62
+
63
+ const name = safeFileName('orders/2026', '.lock'.length + draftSuffixLength);
64
+ await createExclusive(join('/var/lib/app/locks', `${name}.lock`), 'pid 4242');
65
+ // The file is orders%2F2026.lock
66
+ ```
67
+
68
+ - A key that fits gets its URI encoding, with each `.` as `%2E`. Thus a name is never `.` or `..`. The name of `job.lock` is `job%2Elock`, so it is never the name of `job` plus the suffix `.lock`.
69
+ - A key that does not fit, or that is not well-formed Unicode, gets a hashed name: the first 32 characters of its encoding, `%%`, and the SHA-256 of its UTF-16 code units in hex. A hashed name has at most 98 characters. An encoding never holds `%%`, so a hashed name is never the name of a key that fits.
70
+ - The rule does not change between versions. Processes of two versions find the same file for a key.
71
+ - On a case-insensitive file system, keys that differ only by case get one file.
72
+
73
+ ## Try again while Windows refuses a file for a moment
74
+
75
+ Windows refuses a file for a moment while a delete of it is in progress, or while another program holds it open with no sharing, for example a virus scanner. The error codes are `EPERM`, `EACCES`, and `EBUSY`. A real permission error has the same codes. `patiently` runs an operation again while Windows refuses the file, for up to 1 second. After that, the error reaches the caller. On other systems, `patiently` runs the operation one time.
76
+
77
+ ```ts
78
+ import { readFile } from 'node:fs/promises';
79
+
80
+ import { patiently } from '@zukhruf/fs';
81
+
82
+ const content = await patiently(() =>
83
+ readFile('/var/lib/app/job.lock', 'utf8'),
84
+ );
85
+ ```
86
+
87
+ Each write of this package uses `patiently` for its rename or link.
88
+
89
+ ## Lock a file
90
+
91
+ A process can die while it holds a lock. When the lock is a file that is present, for example a file from `createExclusive`, the file stays after the process dies. Then the lock stays until somebody deletes the file. A `FileLock` is a lock that the kernel holds. When the process of the holder stops, however it stops, the kernel frees the lock at once.
92
+
93
+ Node.js has no `flock`. Thus a `FileLock` is an exclusive SQLite transaction on the file. SQLite is part of Node.js (`node:sqlite`), so this package has no dependencies.
94
+
95
+ ```ts
96
+ import { FileLock } from '@zukhruf/fs';
97
+
98
+ using lock = FileLock.open('/var/lib/app/job.lock');
99
+ if (lock.tryLock()) {
100
+ try {
101
+ await runJob();
102
+ } finally {
103
+ lock.unlock();
104
+ }
105
+ } else {
106
+ console.log('Another process holds the job.');
107
+ }
108
+ ```
109
+
110
+ - `FileLock.open(path)` opens the file. When no file is at the path, it creates an empty file. It takes no lock.
111
+ - `tryLock()` takes the lock and returns `true`. When another handle holds the lock, it returns `false` at once. It never waits: to wait, try again after a pause. Two handles in one process exclude each other too.
112
+ - When the file has other content, `tryLock()` throws. Only an empty file or a SQLite database can be a file lock.
113
+ - `unlock()` lets the lock go. The handle stays open, and it can lock again.
114
+ - A disposal closes the handle. A lock that the handle holds goes with it. A second disposal does nothing.
115
+ - A held lock stays held when nothing refers to its handle any more. Only `unlock()`, a disposal, or the end of the process lets it go.
116
+
117
+ `FileLock.check(path)` tells if a handle holds the lock now. It returns `'locked'`, `'unlocked'`, or `'missing'` when no file is at the path. It reads the file one time and never creates it. For that read, it holds a shared lock for a moment, so a `tryLock()` at that moment can return `false`. The answer can be old when you use it. Other faults, for example a directory at the path, throw.
118
+
119
+ Obey these rules:
120
+
121
+ - Open the file only through `FileLock`. On Unix, the lock is a POSIX record lock of the process. When the process closes any other descriptor of the file, the kernel drops the lock and gives no error.
122
+ - Do not delete the file while processes use it. A new file at the path has no lock, so a second process can lock it while the first one holds the old file.
123
+ - Use `assertLocalDirectory` first. Other machines do not see the lock reliably on a network file system.
124
+
125
+ The journal of the transaction is in memory, so a holder that dies leaves no `<path>-journal` file. Other programs can hold an exclusive SQLite transaction on the same file in the default journal mode. Such a transaction and a `FileLock` exclude each other. A journal file that such a program leaves is deleted by the next `tryLock()`.
126
+
127
+ ## Refuse a network directory
128
+
129
+ A lock that a file or SQLite holds works between the processes of one machine. On a network file system, for example NFS or SMB, other machines do not see that lock reliably. `assertLocalDirectory(directory)` rejects with `NetworkDirectoryError` when the directory is on a network file system. A directory that does not exist yet gets the result of its nearest parent that exists.
130
+
131
+ ```ts
132
+ import { NetworkDirectoryError, assertLocalDirectory } from '@zukhruf/fs';
133
+
134
+ try {
135
+ await assertLocalDirectory('/mnt/shared/locks');
136
+ } catch (error) {
137
+ if (error instanceof NetworkDirectoryError) console.log(error.fileSystem); // 'NFS'
138
+ throw error;
139
+ }
140
+ ```
141
+
142
+ - On Linux, the check reads the file system type with `statfs`. FUSE counts as local.
143
+ - On Windows, a UNC path, for example `\\server\share`, is a network directory.
144
+ - On macOS, each directory counts as local, because macOS gives no stable file system type.
145
+ - When `statfs` fails, the check does not reject. The directory is used as if the check did not exist.
146
+
147
+ `@zukhruf/mutex` and `@zukhruf/single-flight` give this same `NetworkDirectoryError` class. A catch with `instanceof` works for each of them.
148
+
149
+ ## Check the error code
150
+
151
+ `isErrno(error, code)` tells if `error` is a Node.js system error with that error code.
152
+
153
+ ```ts
154
+ import { isErrno } from '@zukhruf/fs';
155
+
156
+ try {
157
+ await readFile(path, 'utf8');
158
+ } catch (error) {
159
+ if (!isErrno(error, 'ENOENT')) throw error;
160
+ }
161
+ ```
@@ -0,0 +1,3 @@
1
+ /** Replaces `path` in one step, so readers see the old or the new content, never a partial write. */
2
+ export declare function atomicWrite(path: string, content: string): Promise<void>;
3
+ //# sourceMappingURL=atomic-write.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"atomic-write.d.ts","sourceRoot":"","sources":["../src/atomic-write.ts"],"names":[],"mappings":"AAKA,qGAAqG;AACrG,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,iBAU9D"}
@@ -0,0 +1,17 @@
1
+ import { rm, writeFile } from 'node:fs/promises';
2
+ import { draftOf } from "./draft.js";
3
+ import { replaceFile } from "./replace-file.js";
4
+ /** Replaces `path` in one step, so readers see the old or the new content, never a partial write. */
5
+ export async function atomicWrite(path, content) {
6
+ const draft = draftOf(path);
7
+ try {
8
+ await writeFile(draft, content);
9
+ await replaceFile(draft, path);
10
+ }
11
+ catch (error) {
12
+ // A draft that never replaced `path` would only pile up, one per failed write.
13
+ await rm(draft, { force: true });
14
+ throw error;
15
+ }
16
+ }
17
+ //# sourceMappingURL=atomic-write.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"atomic-write.js","sourceRoot":"","sources":["../src/atomic-write.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAEjD,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,qGAAqG;AACrG,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,OAAe;IAC7D,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC;QACH,MAAM,SAAS,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QAChC,MAAM,WAAW,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IACjC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,+EAA+E;QAC/E,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACjC,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Creates `path` holding `content` unless it already exists. Linking a fully
3
+ * written file makes creation atomic, so the file is never seen empty.
4
+ */
5
+ export declare function createExclusive(path: string, content: string): Promise<boolean>;
6
+ //# sourceMappingURL=create-exclusive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-exclusive.d.ts","sourceRoot":"","sources":["../src/create-exclusive.ts"],"names":[],"mappings":"AAMA;;;GAGG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,oBAYlE"}
@@ -0,0 +1,29 @@
1
+ import { link, unlink, writeFile } from 'node:fs/promises';
2
+ import { draftOf } from "./draft.js";
3
+ import { isErrno } from "./errno.js";
4
+ import { patiently } from "./patiently.js";
5
+ /**
6
+ * Creates `path` holding `content` unless it already exists. Linking a fully
7
+ * written file makes creation atomic, so the file is never seen empty.
8
+ */
9
+ export async function createExclusive(path, content) {
10
+ const draft = draftOf(path);
11
+ await writeFile(draft, content);
12
+ try {
13
+ await patiently(() => link(draft, path));
14
+ return true;
15
+ }
16
+ catch (error) {
17
+ if (isErrno(error, 'EEXIST'))
18
+ return false;
19
+ throw error;
20
+ }
21
+ finally {
22
+ await removeDraft(draft);
23
+ }
24
+ }
25
+ /** A draft left behind has a unique name, so it never blocks a key; failing to remove it must not change whether `path` was created. */
26
+ async function removeDraft(draft) {
27
+ await patiently(() => unlink(draft)).catch(() => { });
28
+ }
29
+ //# sourceMappingURL=create-exclusive.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-exclusive.js","sourceRoot":"","sources":["../src/create-exclusive.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE3D,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,OAAe;IACjE,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,MAAM,SAAS,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAChC,IAAI,CAAC;QACH,MAAM,SAAS,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;QACzC,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC;YAAE,OAAO,KAAK,CAAC;QAC3C,MAAM,KAAK,CAAC;IACd,CAAC;YAAS,CAAC;QACT,MAAM,WAAW,CAAC,KAAK,CAAC,CAAC;IAC3B,CAAC;AACH,CAAC;AAED,wIAAwI;AACxI,KAAK,UAAU,WAAW,CAAC,KAAa;IACtC,MAAM,SAAS,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;AACvD,CAAC"}
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A write fills a draft beside `path` and then puts it in place in one step,
3
+ * so a reader sees the old content or the new content, never a part of it.
4
+ * The random name keeps two writers of one path apart.
5
+ */
6
+ export declare const draftOf: (path: string) => string;
7
+ /**
8
+ * The length of what a write adds to a path to name its draft. A caller that
9
+ * makes long file names keeps this much room, or the draft's name is too long
10
+ * for the file system.
11
+ */
12
+ export declare const draftSuffixLength: number;
13
+ //# sourceMappingURL=draft.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"draft.d.ts","sourceRoot":"","sources":["../src/draft.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,eAAO,MAAM,OAAO,GAAI,MAAM,MAAM,WAAkC,CAAC;AAEvE;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,QAAqB,CAAC"}
package/dist/draft.js ADDED
@@ -0,0 +1,14 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ /**
3
+ * A write fills a draft beside `path` and then puts it in place in one step,
4
+ * so a reader sees the old content or the new content, never a part of it.
5
+ * The random name keeps two writers of one path apart.
6
+ */
7
+ export const draftOf = (path) => `${path}.${randomUUID()}.tmp`;
8
+ /**
9
+ * The length of what a write adds to a path to name its draft. A caller that
10
+ * makes long file names keeps this much room, or the draft's name is too long
11
+ * for the file system.
12
+ */
13
+ export const draftSuffixLength = draftOf('').length;
14
+ //# sourceMappingURL=draft.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"draft.js","sourceRoot":"","sources":["../src/draft.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;GAIG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,GAAG,IAAI,IAAI,UAAU,EAAE,MAAM,CAAC;AAEvE;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,OAAO,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Replaces `path` in one step like `atomicWrite`, and returns only once both
3
+ * the new content and the replacement survive a power loss: a counter that
4
+ * goes back after a crash would hand out a token twice.
5
+ */
6
+ export declare function durableWrite(path: string, content: string): Promise<void>;
7
+ //# sourceMappingURL=durable-write.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable-write.d.ts","sourceRoot":"","sources":["../src/durable-write.ts"],"names":[],"mappings":"AAOA;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,iBAW/D"}
@@ -0,0 +1,46 @@
1
+ import { open, rm } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { draftOf } from "./draft.js";
4
+ import { isErrno } from "./errno.js";
5
+ import { replaceFile } from "./replace-file.js";
6
+ /**
7
+ * Replaces `path` in one step like `atomicWrite`, and returns only once both
8
+ * the new content and the replacement survive a power loss: a counter that
9
+ * goes back after a crash would hand out a token twice.
10
+ */
11
+ export async function durableWrite(path, content) {
12
+ const draft = draftOf(path);
13
+ try {
14
+ await writeSynced(draft, content);
15
+ await replaceFile(draft, path);
16
+ }
17
+ catch (error) {
18
+ // A draft that never replaced `path` would only pile up, one per failed write.
19
+ await rm(draft, { force: true });
20
+ throw error;
21
+ }
22
+ await syncDirectory(dirname(path));
23
+ }
24
+ async function writeSynced(path, content) {
25
+ await using handle = await open(path, 'wx');
26
+ await handle.writeFile(content);
27
+ await handle.sync();
28
+ }
29
+ /**
30
+ * A rename lives in its directory, so the directory must reach the disk too.
31
+ * Windows cannot open a directory to sync it, and some file systems refuse to
32
+ * sync one; there the rename is as durable as that system makes it.
33
+ */
34
+ async function syncDirectory(directory) {
35
+ if (process.platform === 'win32')
36
+ return;
37
+ await using handle = await open(directory, 'r');
38
+ try {
39
+ await handle.sync();
40
+ }
41
+ catch (error) {
42
+ if (!isErrno(error, 'EINVAL') && !isErrno(error, 'ENOTSUP'))
43
+ throw error;
44
+ }
45
+ }
46
+ //# sourceMappingURL=durable-write.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"durable-write.js","sourceRoot":"","sources":["../src/durable-write.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAY,EAAE,OAAe;IAC9D,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC;QACH,MAAM,WAAW,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;QAClC,MAAM,WAAW,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IACjC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,+EAA+E;QAC/E,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACjC,MAAM,KAAK,CAAC;IACd,CAAC;IACD,MAAM,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;AACrC,CAAC;AAED,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,OAAe;IACtD,YAAY,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC5C,MAAM,MAAM,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAChC,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;AACtB,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,aAAa,CAAC,SAAiB;IAC5C,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO;IACzC,YAAY,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAChD,IAAI,CAAC;QACH,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;IACtB,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,SAAS,CAAC;YAAE,MAAM,KAAK,CAAC;IAC3E,CAAC;AACH,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare function isErrno(error: unknown, code: string): boolean;
2
+ //# sourceMappingURL=errno.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errno.d.ts","sourceRoot":"","sources":["../src/errno.ts"],"names":[],"mappings":"AAAA,wBAAgB,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAE7D"}
package/dist/errno.js ADDED
@@ -0,0 +1,4 @@
1
+ export function isErrno(error, code) {
2
+ return error instanceof Error && 'code' in error && error.code === code;
3
+ }
4
+ //# sourceMappingURL=errno.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errno.js","sourceRoot":"","sources":["../src/errno.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,OAAO,CAAC,KAAc,EAAE,IAAY;IAClD,OAAO,KAAK,YAAY,KAAK,IAAI,MAAM,IAAI,KAAK,IAAI,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC;AAC1E,CAAC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A lock on a file that the kernel holds: an exclusive SQLite transaction on
3
+ * the file. The kernel frees it when the holder's process dies, however it
4
+ * dies, so a crash never leaves the file locked.
5
+ *
6
+ * On Unix the lock is a POSIX record lock, which the kernel ties to the
7
+ * process and the file. Closing any other descriptor of the file in this
8
+ * process drops the lock without a word, so open the file only through
9
+ * `FileLock`. A network file system does not share the lock reliably between
10
+ * machines: check the directory with `assertLocalDirectory` first.
11
+ */
12
+ export declare class FileLock implements Disposable {
13
+ #private;
14
+ private constructor();
15
+ /** Opens the file, and creates it empty when it is absent. Takes no lock. */
16
+ static open(path: string): FileLock;
17
+ /**
18
+ * Whether a handle holds the lock on the file at `path` now. It reads
19
+ * the file once, read-only, and never creates it. For that read it takes a
20
+ * shared lock, so a `tryLock` at that moment can return `false`. The answer
21
+ * can be stale as soon as it returns.
22
+ */
23
+ static check(path: string): 'locked' | 'unlocked' | 'missing';
24
+ /** Takes the lock without waiting. Returns `false` when another handle holds it. */
25
+ tryLock(): boolean;
26
+ /** Lets the lock go. The handle stays open for the next `tryLock`. */
27
+ unlock(): void;
28
+ /** Closes the handle. A lock that it holds goes with it. */
29
+ [Symbol.dispose](): void;
30
+ }
31
+ //# sourceMappingURL=file-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-lock.d.ts","sourceRoot":"","sources":["../src/file-lock.ts"],"names":[],"mappings":"AASA;;;;;;;;;;GAUG;AACH,qBAAa,QAAS,YAAW,UAAU;;IAIzC,OAAO;IAKP,6EAA6E;IAC7E,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ;IAMnC;;;;;OAKG;IACH,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS;IAsB7D,oFAAoF;IACpF,OAAO,IAAI,OAAO;IAmBlB,sEAAsE;IACtE,MAAM,IAAI,IAAI;IAUd,4DAA4D;IAC5D,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;CAIzB"}
@@ -0,0 +1,102 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ const SQLITE_BUSY = 5;
4
+ const SQLITE_CANTOPEN = 14;
5
+ /** Garbage collection closes a connection that nobody references, and a lock that it holds goes with it. */
6
+ const held = new Set();
7
+ /**
8
+ * A lock on a file that the kernel holds: an exclusive SQLite transaction on
9
+ * the file. The kernel frees it when the holder's process dies, however it
10
+ * dies, so a crash never leaves the file locked.
11
+ *
12
+ * On Unix the lock is a POSIX record lock, which the kernel ties to the
13
+ * process and the file. Closing any other descriptor of the file in this
14
+ * process drops the lock without a word, so open the file only through
15
+ * `FileLock`. A network file system does not share the lock reliably between
16
+ * machines: check the directory with `assertLocalDirectory` first.
17
+ */
18
+ export class FileLock {
19
+ #path;
20
+ #database;
21
+ constructor(path, database) {
22
+ this.#path = path;
23
+ this.#database = database;
24
+ }
25
+ /** Opens the file, and creates it empty when it is absent. Takes no lock. */
26
+ static open(path) {
27
+ // No busy timeout (Node's default is 0): one above zero blocks the event
28
+ // loop while it waits, so a caller tries again instead.
29
+ return new FileLock(path, new DatabaseSync(path));
30
+ }
31
+ /**
32
+ * Whether a handle holds the lock on the file at `path` now. It reads
33
+ * the file once, read-only, and never creates it. For that read it takes a
34
+ * shared lock, so a `tryLock` at that moment can return `false`. The answer
35
+ * can be stale as soon as it returns.
36
+ */
37
+ static check(path) {
38
+ let database;
39
+ try {
40
+ database = new DatabaseSync(path, { readOnly: true });
41
+ }
42
+ catch (error) {
43
+ // A file that exists but cannot be opened is a fault, not a missing file.
44
+ if (hasResultCode(error, SQLITE_CANTOPEN) && !existsSync(path)) {
45
+ return 'missing';
46
+ }
47
+ throw error;
48
+ }
49
+ try {
50
+ database.prepare('SELECT count(*) FROM sqlite_schema').get();
51
+ return 'unlocked';
52
+ }
53
+ catch (error) {
54
+ if (hasResultCode(error, SQLITE_BUSY))
55
+ return 'locked';
56
+ throw error;
57
+ }
58
+ finally {
59
+ database.close();
60
+ }
61
+ }
62
+ /** Takes the lock without waiting. Returns `false` when another handle holds it. */
63
+ tryLock() {
64
+ if (this.#database.isTransaction) {
65
+ throw new Error(`This handle holds the file lock ${JSON.stringify(this.#path)} already.`);
66
+ }
67
+ try {
68
+ // A journal on disk would outlive a holder that dies. The statement is
69
+ // busy too while another handle holds the lock, so each try runs it.
70
+ this.#database.exec('PRAGMA journal_mode = MEMORY');
71
+ this.#database.exec('BEGIN EXCLUSIVE');
72
+ }
73
+ catch (error) {
74
+ if (hasResultCode(error, SQLITE_BUSY))
75
+ return false;
76
+ throw error;
77
+ }
78
+ held.add(this.#database);
79
+ return true;
80
+ }
81
+ /** Lets the lock go. The handle stays open for the next `tryLock`. */
82
+ unlock() {
83
+ if (!this.#database.isTransaction) {
84
+ throw new Error(`This handle does not hold the file lock ${JSON.stringify(this.#path)}.`);
85
+ }
86
+ this.#database.exec('ROLLBACK');
87
+ held.delete(this.#database);
88
+ }
89
+ /** Closes the handle. A lock that it holds goes with it. */
90
+ [Symbol.dispose]() {
91
+ held.delete(this.#database);
92
+ this.#database[Symbol.dispose]();
93
+ }
94
+ }
95
+ /** The low byte of SQLite's extended result code is the primary code, so this covers each variant of `code`. */
96
+ function hasResultCode(error, code) {
97
+ return (error instanceof Error &&
98
+ 'errcode' in error &&
99
+ typeof error.errcode === 'number' &&
100
+ (error.errcode & 0xff) === code);
101
+ }
102
+ //# sourceMappingURL=file-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-lock.js","sourceRoot":"","sources":["../src/file-lock.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ export { atomicWrite } from './atomic-write.ts';
2
+ export { createExclusive } from './create-exclusive.ts';
3
+ export { draftSuffixLength } from './draft.ts';
4
+ export { durableWrite } from './durable-write.ts';
5
+ export { isErrno } from './errno.ts';
6
+ export { FileLock } from './file-lock.ts';
7
+ export { assertLocalDirectory } from './local-directory.ts';
8
+ export { NetworkDirectoryError } from './network-directory-error.ts';
9
+ export { patiently } from './patiently.ts';
10
+ export { safeFileName } from './safe-file-name.ts';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export { atomicWrite } from "./atomic-write.js";
2
+ export { createExclusive } from "./create-exclusive.js";
3
+ export { draftSuffixLength } from "./draft.js";
4
+ export { durableWrite } from "./durable-write.js";
5
+ export { isErrno } from "./errno.js";
6
+ export { FileLock } from "./file-lock.js";
7
+ export { assertLocalDirectory } from "./local-directory.js";
8
+ export { NetworkDirectoryError } from "./network-directory-error.js";
9
+ export { patiently } from "./patiently.js";
10
+ export { safeFileName } from "./safe-file-name.js";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Rejects with `NetworkDirectoryError` when `directory` is on a network file
3
+ * system, where file locks are not shared reliably between machines.
4
+ * A directory that does not exist yet is judged by its nearest existing parent.
5
+ */
6
+ export declare function assertLocalDirectory(directory: string): Promise<void>;
7
+ //# sourceMappingURL=local-directory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-directory.d.ts","sourceRoot":"","sources":["../src/local-directory.ts"],"names":[],"mappings":"AA+DA;;;;GAIG;AACH,wBAAsB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQ3E"}
@@ -0,0 +1,75 @@
1
+ import { statfs } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { isErrno } from "./errno.js";
4
+ import { NetworkDirectoryError } from "./network-directory-error.js";
5
+ /**
6
+ * The `f_type` Linux's statfs reports for each network file system: from the
7
+ * kernel's uapi/linux/magic.h, gfs2_ondisk.h (GFS2) and Lustre's lustre_user.h.
8
+ * FUSE is left out on purpose: on one kernel, its mounts hold file locks.
9
+ */
10
+ const linuxNetworkFileSystems = new Map([
11
+ [0x6969n, 'NFS'],
12
+ [0x517bn, 'SMB'],
13
+ [0xff534d42n, 'CIFS'],
14
+ [0xfe534d42n, 'SMB2'],
15
+ [0x00c36400n, 'Ceph'],
16
+ [0x5346414fn, 'AFS'],
17
+ [0x6b414653n, 'kAFS'],
18
+ [0x73757245n, 'Coda'],
19
+ [0x564cn, 'NCP'],
20
+ [0x7461636fn, 'OCFS2'],
21
+ [0x01161970n, 'GFS2'],
22
+ [0x0bd00bd0n, 'Lustre'],
23
+ [0x01021997n, '9p'],
24
+ ]);
25
+ const local = { kind: 'local' };
26
+ const unknown = { kind: 'unknown' };
27
+ /**
28
+ * How each platform judges a directory. Only Linux reports a stable file
29
+ * system type; macOS numbers its file systems as they load, so it has no
30
+ * judge, and every directory counts as local there.
31
+ */
32
+ const judges = {
33
+ linux: (path) => statfsOfNearest(path).then(({ type }) => {
34
+ // f_type is a signed long, so a magic with its high bit set can arrive sign-extended.
35
+ const fileSystem = linuxNetworkFileSystems.get(BigInt.asUintN(32, type));
36
+ return fileSystem ? { kind: 'network', fileSystem } : local;
37
+ }, () => unknown),
38
+ win32: async (path) => isUncPath(path) ? { kind: 'network', fileSystem: 'UNC share' } : local,
39
+ };
40
+ /** A directory's file system does not change under a running process, so each local one is judged once. */
41
+ const localDirectories = new Set();
42
+ /**
43
+ * Rejects with `NetworkDirectoryError` when `directory` is on a network file
44
+ * system, where file locks are not shared reliably between machines.
45
+ * A directory that does not exist yet is judged by its nearest existing parent.
46
+ */
47
+ export async function assertLocalDirectory(directory) {
48
+ const path = resolve(directory);
49
+ if (localDirectories.has(path))
50
+ return;
51
+ const judgement = (await judges[process.platform]?.(path)) ?? local;
52
+ if (judgement.kind === 'network') {
53
+ throw new NetworkDirectoryError(directory, judgement.fileSystem);
54
+ }
55
+ if (judgement.kind === 'local')
56
+ localDirectories.add(path);
57
+ }
58
+ async function statfsOfNearest(path) {
59
+ try {
60
+ return await statfs(path, { bigint: true });
61
+ }
62
+ catch (error) {
63
+ const parent = dirname(path);
64
+ if (!isErrno(error, 'ENOENT') || parent === path)
65
+ throw error;
66
+ return statfsOfNearest(parent);
67
+ }
68
+ }
69
+ /** `\\server\share`, `\\?\UNC\server\share` and `\\.\UNC\server\share`; `\\?\C:\` names a local drive. */
70
+ function isUncPath(path) {
71
+ if (/^\\\\[?.]\\/.test(path))
72
+ return /^\\\\[?.]\\UNC\\/i.test(path);
73
+ return path.startsWith('\\\\');
74
+ }
75
+ //# sourceMappingURL=local-directory.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-directory.js","sourceRoot":"","sources":["../src/local-directory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAE7C,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AACrC,OAAO,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAErE;;;;GAIG;AACH,MAAM,uBAAuB,GAAG,IAAI,GAAG,CAAiB;IACtD,CAAC,OAAO,EAAE,KAAK,CAAC;IAChB,CAAC,OAAO,EAAE,KAAK,CAAC;IAChB,CAAC,WAAW,EAAE,MAAM,CAAC;IACrB,CAAC,WAAW,EAAE,MAAM,CAAC;IACrB,CAAC,WAAW,EAAE,MAAM,CAAC;IACrB,CAAC,WAAW,EAAE,KAAK,CAAC;IACpB,CAAC,WAAW,EAAE,MAAM,CAAC;IACrB,CAAC,WAAW,EAAE,MAAM,CAAC;IACrB,CAAC,OAAO,EAAE,KAAK,CAAC;IAChB,CAAC,WAAW,EAAE,OAAO,CAAC;IACtB,CAAC,WAAW,EAAE,MAAM,CAAC;IACrB,CAAC,WAAW,EAAE,QAAQ,CAAC;IACvB,CAAC,WAAW,EAAE,IAAI,CAAC;CACpB,CAAC,CAAC;AASH,MAAM,KAAK,GAAc,EAAE,IAAI,EAAE,OAAO,EAAE,CAAC;AAC3C,MAAM,OAAO,GAAc,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAE/C;;;;GAIG;AACH,MAAM,MAAM,GAER;IACF,KAAK,EAAE,CAAC,IAAI,EAAE,EAAE,CACd,eAAe,CAAC,IAAI,CAAC,CAAC,IAAI,CACxB,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE;QACX,sFAAsF;QACtF,MAAM,UAAU,GAAG,uBAAuB,CAAC,GAAG,CAC5C,MAAM,CAAC,OAAO,CAAC,EAAE,EAAE,IAAI,CAAC,CACzB,CAAC;QACF,OAAO,UAAU,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC;IAC9D,CAAC,EACD,GAAG,EAAE,CAAC,OAAO,CACd;IACH,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,CACpB,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,KAAK;CACzE,CAAC;AAEF,2GAA2G;AAC3G,MAAM,gBAAgB,GAAG,IAAI,GAAG,EAAU,CAAC;AAE3C;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,SAAiB;IAC1D,MAAM,IAAI,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,IAAI,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO;IACvC,MAAM,SAAS,GAAG,CAAC,MAAM,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,IAAI,KAAK,CAAC;IACpE,IAAI,SAAS,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACjC,MAAM,IAAI,qBAAqB,CAAC,SAAS,EAAE,SAAS,CAAC,UAAU,CAAC,CAAC;IACnE,CAAC;IACD,IAAI,SAAS,CAAC,IAAI,KAAK,OAAO;QAAE,gBAAgB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAC7D,CAAC;AAED,KAAK,UAAU,eAAe,CAAC,IAAY;IACzC,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,MAAM,KAAK,IAAI;YAAE,MAAM,KAAK,CAAC;QAC9D,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;AACH,CAAC;AAED,0GAA0G;AAC1G,SAAS,SAAS,CAAC,IAAY;IAC7B,IAAI,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,OAAO,mBAAmB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpE,OAAO,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC;AACjC,CAAC"}
@@ -0,0 +1,8 @@
1
+ /** The directory is on a file system that other machines share, where file locks do not hold. */
2
+ export declare class NetworkDirectoryError extends Error {
3
+ readonly directory: string;
4
+ /** The network file system the directory is on, for example `NFS`. */
5
+ readonly fileSystem: string;
6
+ constructor(directory: string, fileSystem: string);
7
+ }
8
+ //# sourceMappingURL=network-directory-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network-directory-error.d.ts","sourceRoot":"","sources":["../src/network-directory-error.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,sEAAsE;IACtE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;gBAEhB,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM;CAQlD"}
@@ -0,0 +1,13 @@
1
+ /** The directory is on a file system that other machines share, where file locks do not hold. */
2
+ export class NetworkDirectoryError extends Error {
3
+ directory;
4
+ /** The network file system the directory is on, for example `NFS`. */
5
+ fileSystem;
6
+ constructor(directory, fileSystem) {
7
+ super(`The directory ${JSON.stringify(directory)} is on a network file system (${fileSystem}), where file locks are not shared reliably between machines. Use a local directory.`);
8
+ this.name = 'NetworkDirectoryError';
9
+ this.directory = directory;
10
+ this.fileSystem = fileSystem;
11
+ }
12
+ }
13
+ //# sourceMappingURL=network-directory-error.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network-directory-error.js","sourceRoot":"","sources":["../src/network-directory-error.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,MAAM,OAAO,qBAAsB,SAAQ,KAAK;IACrC,SAAS,CAAS;IAC3B,sEAAsE;IAC7D,UAAU,CAAS;IAE5B,YAAY,SAAiB,EAAE,UAAkB;QAC/C,KAAK,CACH,iBAAiB,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,iCAAiC,UAAU,sFAAsF,CAC5K,CAAC;QACF,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;QACpC,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,UAAU,GAAG,UAAU,CAAC;IAC/B,CAAC;CACF"}
@@ -0,0 +1,3 @@
1
+ /** Runs `operation`, and runs it again while Windows refuses the file for a moment; after that, the error reaches the caller. */
2
+ export declare function patiently<T>(operation: () => Promise<T>): Promise<T>;
3
+ //# sourceMappingURL=patiently.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patiently.d.ts","sourceRoot":"","sources":["../src/patiently.ts"],"names":[],"mappings":"AAoBA,iIAAiI;AACjI,wBAAsB,SAAS,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAc1E"}
@@ -0,0 +1,30 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { isErrno } from "./errno.js";
3
+ /** How long to retry a file operation that Windows refuses for a moment. */
4
+ const WINDOWS_PATIENCE = 1000;
5
+ /**
6
+ * Windows refuses a file for a moment while another program holds it open
7
+ * with no sharing, as a virus scanner can, or while a delete of it is in
8
+ * progress. The error code is the same as for a real permission denial, so a
9
+ * refusal is retried only for a limited time.
10
+ */
11
+ function isRefusedForNow(error) {
12
+ return (process.platform === 'win32' &&
13
+ ['EPERM', 'EACCES', 'EBUSY'].some((code) => isErrno(error, code)));
14
+ }
15
+ /** Runs `operation`, and runs it again while Windows refuses the file for a moment; after that, the error reaches the caller. */
16
+ export async function patiently(operation) {
17
+ const started = performance.now();
18
+ for (let attempt = 0;; attempt++) {
19
+ try {
20
+ return await operation();
21
+ }
22
+ catch (error) {
23
+ if (!isRefusedForNow(error) ||
24
+ performance.now() - started > WINDOWS_PATIENCE)
25
+ throw error;
26
+ await delay(Math.min(5 * 2 ** attempt, 100));
27
+ }
28
+ }
29
+ }
30
+ //# sourceMappingURL=patiently.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patiently.js","sourceRoot":"","sources":["../src/patiently.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,IAAI,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAE3D,OAAO,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAErC,4EAA4E;AAC5E,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAE9B;;;;;GAKG;AACH,SAAS,eAAe,CAAC,KAAc;IACrC,OAAO,CACL,OAAO,CAAC,QAAQ,KAAK,OAAO;QAC5B,CAAC,OAAO,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAClE,CAAC;AACJ,CAAC;AAED,iIAAiI;AACjI,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,SAA2B;IAC5D,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;IAClC,KAAK,IAAI,OAAO,GAAG,CAAC,GAAI,OAAO,EAAE,EAAE,CAAC;QAClC,IAAI,CAAC;YACH,OAAO,MAAM,SAAS,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IACE,CAAC,eAAe,CAAC,KAAK,CAAC;gBACvB,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,GAAG,gBAAgB;gBAE9C,MAAM,KAAK,CAAC;YACd,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,IAAI,OAAO,EAAE,GAAG,CAAC,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Moves `draft` over `path` in one step. On Windows, a rename over a file
3
+ * fails while another process has that file open, for example a waiter that
4
+ * reads it at that moment. The refusal is brief, so the rename is tried again;
5
+ * it stays the single commit point.
6
+ */
7
+ export declare function replaceFile(draft: string, path: string): Promise<void>;
8
+ //# sourceMappingURL=replace-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-file.d.ts","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,iBAEtD"}
@@ -0,0 +1,12 @@
1
+ import { rename } from 'node:fs/promises';
2
+ import { patiently } from "./patiently.js";
3
+ /**
4
+ * Moves `draft` over `path` in one step. On Windows, a rename over a file
5
+ * fails while another process has that file open, for example a waiter that
6
+ * reads it at that moment. The refusal is brief, so the rename is tried again;
7
+ * it stays the single commit point.
8
+ */
9
+ export function replaceFile(draft, path) {
10
+ return patiently(() => rename(draft, path));
11
+ }
12
+ //# sourceMappingURL=replace-file.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-file.js","sourceRoot":"","sources":["../src/replace-file.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa,EAAE,IAAY;IACrD,OAAO,SAAS,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;AAC9C,CAAC"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Maps any key to a single path segment that still fits a file name after the
3
+ * caller adds up to `longestSuffix` characters. Encoding every dot means a key
4
+ * can never be `.` or `..`, and suffixes such as `.lock` can never collide with
5
+ * a key. On a case-insensitive file system, keys that differ only by case share
6
+ * a name and so one file. A lock file then guards more keys than it must, but
7
+ * never fewer.
8
+ *
9
+ * A key whose encoding does not fit, or that is not well-formed Unicode and so
10
+ * has no encoding, gets the start of its encoding, `%%`, and the SHA-256 of its
11
+ * UTF-16 code units. A key that fits keeps the name earlier versions gave it, so
12
+ * processes of two versions share its file. No encoding contains `%%`, so the
13
+ * two kinds of name never meet; the digest is hex, whose one case cannot fold
14
+ * two digests into one name; and the code units keep lone surrogates apart.
15
+ */
16
+ export declare function safeFileName(key: string, longestSuffix: number): string;
17
+ //# sourceMappingURL=safe-file-name.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safe-file-name.d.ts","sourceRoot":"","sources":["../src/safe-file-name.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAYvE"}
@@ -0,0 +1,38 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** The longest file name that ext4, APFS, NTFS and tmpfs accept. */
3
+ const longestFileName = 255;
4
+ /** Enough of a long key's start to recognize it in a directory listing. */
5
+ const visibleStart = 32;
6
+ /**
7
+ * Maps any key to a single path segment that still fits a file name after the
8
+ * caller adds up to `longestSuffix` characters. Encoding every dot means a key
9
+ * can never be `.` or `..`, and suffixes such as `.lock` can never collide with
10
+ * a key. On a case-insensitive file system, keys that differ only by case share
11
+ * a name and so one file. A lock file then guards more keys than it must, but
12
+ * never fewer.
13
+ *
14
+ * A key whose encoding does not fit, or that is not well-formed Unicode and so
15
+ * has no encoding, gets the start of its encoding, `%%`, and the SHA-256 of its
16
+ * UTF-16 code units. A key that fits keeps the name earlier versions gave it, so
17
+ * processes of two versions share its file. No encoding contains `%%`, so the
18
+ * two kinds of name never meet; the digest is hex, whose one case cannot fold
19
+ * two digests into one name; and the code units keep lone surrogates apart.
20
+ */
21
+ export function safeFileName(key, longestSuffix) {
22
+ if (key.isWellFormed()) {
23
+ const encoded = encode(key);
24
+ if (encoded.length + longestSuffix <= longestFileName)
25
+ return encoded;
26
+ }
27
+ const start = encode(key.toWellFormed())
28
+ .slice(0, visibleStart)
29
+ .replace(/%[0-9A-F]?$/, '');
30
+ const digest = createHash('sha256')
31
+ .update(Buffer.from(key, 'utf16le'))
32
+ .digest('hex');
33
+ return `${start}%%${digest}`;
34
+ }
35
+ function encode(key) {
36
+ return encodeURIComponent(key).replaceAll('.', '%2E');
37
+ }
38
+ //# sourceMappingURL=safe-file-name.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"safe-file-name.js","sourceRoot":"","sources":["../src/safe-file-name.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,oEAAoE;AACpE,MAAM,eAAe,GAAG,GAAG,CAAC;AAE5B,2EAA2E;AAC3E,MAAM,YAAY,GAAG,EAAE,CAAC;AAExB;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW,EAAE,aAAqB;IAC7D,IAAI,GAAG,CAAC,YAAY,EAAE,EAAE,CAAC;QACvB,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,OAAO,CAAC,MAAM,GAAG,aAAa,IAAI,eAAe;YAAE,OAAO,OAAO,CAAC;IACxE,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,CAAC;SACrC,KAAK,CAAC,CAAC,EAAE,YAAY,CAAC;SACtB,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;IAC9B,MAAM,MAAM,GAAG,UAAU,CAAC,QAAQ,CAAC;SAChC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;SACnC,MAAM,CAAC,KAAK,CAAC,CAAC;IACjB,OAAO,GAAG,KAAK,KAAK,MAAM,EAAE,CAAC;AAC/B,CAAC;AAED,SAAS,MAAM,CAAC,GAAW;IACzB,OAAO,kBAAkB,CAAC,GAAG,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;AACxD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@zukhruf/fs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.18",
4
+ "description": "File writes that a reader never sees half done: replace a file in one step, with or without a sync to the disk, or create it only when it is absent. A lock on a file that the kernel frees when its holder dies. Also retries the short refusals of Windows, and names a file after any key.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/JanuaryLabs/zukhruf",
9
+ "directory": "packages/fs"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/JanuaryLabs/zukhruf/issues"
13
+ },
14
+ "homepage": "https://github.com/JanuaryLabs/zukhruf/tree/main/packages/fs#readme",
15
+ "keywords": [
16
+ "fs",
17
+ "atomic",
18
+ "write",
19
+ "durable",
20
+ "fsync",
21
+ "rename",
22
+ "exclusive",
23
+ "lock",
24
+ "file-lock",
25
+ "flock",
26
+ "sqlite",
27
+ "windows",
28
+ "eperm"
29
+ ],
30
+ "type": "module",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ "./package.json": "./package.json",
34
+ ".": {
35
+ "@zukhruf/source": "./src/index.ts",
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js",
38
+ "default": "./dist/index.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "!dist/spec/**",
44
+ "!**/*.tsbuildinfo",
45
+ "!**/*.test.*"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }