@worldapptechnologies/nulu-atomic-write 0.0.0-stage → 1.6.4

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
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,134 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "Atomic file replacement and cross-process writer locking for packages that must never leave partial, symlink-hijacked, or wider-permission content on disk."
3
+ kind: "package-library"
4
+ ---
2
5
 
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.
6
+ # @worldapptechnologies/nulu-atomic-write
7
+
8
+
9
+ ## Summary
10
+
11
+ Use `nulu-atomic-write` to replace a file without exposing partial content or following a symlinked temporary path. Its writer lock serializes read-modify-write cycles across processes so concurrent writers cannot overwrite one another with stale state. Each replacement uses caller-selected permission bits on a fresh inode, which safely narrows an existing file's permissions. This zero-dependency library accepts strings; it does not provide a `cordis.yml` plugin or crash durability because it does not call `fsync`.
12
+
13
+ ## Table of Contents
14
+
15
+ - [Use this package](#use-this-package)
16
+ - [Understand the implementation](#understand-the-implementation)
17
+ - [Further Exploration](#further-exploration)
18
+ - [Model Experience](#model-experience)
19
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
20
+ - [Dev Note](#dev-note)
21
+
22
+ -----
23
+
24
+ <a id="use-this-package"></a>
25
+ ## Use this package
26
+
27
+ Use `writeFileAtomic` when a file-backed store must replace one already-rendered string without ever exposing a partial, symlink-hijacked, or wider-permission state, and `withFileLock` when several processes read-modify-write the same file. The smallest path is one call with the final content and the replacement's permission bits.
28
+
29
+ ### Writing a file atomically
30
+
31
+ ```ts
32
+ import { writeFileAtomic } from '@worldapptechnologies/nulu-atomic-write'
33
+
34
+ declare const text: string
35
+ await writeFileAtomic('/home/u/.nulu/settings.yaml', text, { mode: 0o600 })
36
+ ```
37
+
38
+ Parent directories are created as needed, and readers observe either the old or the new complete content. On Windows, transient replacement interference reported as `EACCES`, `EBUSY`, or `EPERM` is retried for a bounded interval; any remaining failure removes the temporary file and leaves the target untouched.
39
+
40
+ ### Coordinating writers
41
+
42
+ For a read-render-commit cycle that a bare atomic commit cannot make safe on its own, hold the writer lock around the operation:
43
+
44
+ ```text
45
+ import { withFileLock, writeFileAtomic } from '@worldapptechnologies/nulu-atomic-write'
46
+
47
+ declare const render: (previous: string) => string
48
+ declare const readCurrent: () => Promise<string>
49
+
50
+ await withFileLock('/home/u/.nulu/settings.yaml', async () => {
51
+ const previous = await readCurrent()
52
+ await writeFileAtomic('/home/u/.nulu/settings.yaml', render(previous), { mode: 0o600 })
53
+ })
54
+ ```
55
+
56
+ Only writers contend — readers never take the lock — and a contender backs off exponentially and fails with a timed-out error rather than blocking forever. How long a contender waits is stated per call through `waitMs`: the default is sized for file work alone, so a holder whose cycle includes a network round trip — a credential mutation that refreshes an expired token — states a longer one, because leaving the default would fail every other writer of that file for the duration. The retry cadence stays fixed. A contender never removes an existing lock, because file age cannot prove that its owner stopped.
57
+
58
+ ### Failures to plan for
59
+
60
+ The lock's parent directory must already exist, so `withFileLock` rejects an invalid parent hierarchy before running the operation. A process that exits while holding the lock leaves the lock sibling behind; later writers time out, and an operator removes it only after verifying that no writer still owns it.
61
+
62
+ -----
63
+
64
+ <a id="understand-the-implementation"></a>
65
+ ## Understand the implementation
66
+
67
+ <details>
68
+ <summary>Implementation internals — click to expand</summary>
69
+
70
+ The package is built on one separation: the atomic commit owns the swap, and the writer lock owns cross-process ordering.
71
+
72
+ ### Source map
73
+
74
+ | File | Role |
75
+ |---|---|
76
+ | [`src/index.ts`](src/index.ts) | `writeFileAtomic` and `withFileLock`, the package's whole surface |
77
+ | — | No runtime invariant companion is published; this pure filesystem primitive owns no event stream or mutable runtime data; its replacement contract is enforced by unit tests. |
78
+
79
+ ### Write path
80
+
81
+ `writeFileAtomic` writes a random-suffix sibling opened with exclusive create (`wx`), then renames it over the target. The exclusive open refuses to follow a symlink planted at a guessable temp path; the same-directory sibling keeps the rename on one filesystem; and the rename replaces a symlinked target itself instead of writing through to its referent. A Windows retry keeps the same complete sibling and uses bounded exponential backoff, so temporary use of the target by software outside the cooperative writer lock cannot turn a safe replacement into an immediate failure; the archived [retry decision record](../../../.agents/notes/archived/bug-fix/2026-08-29-windows-atomic-replace-retry.md) documents the original rationale and rejected alternatives.
82
+
83
+ `withFileLock` creates a `<filename>.lock` sibling with `wx`. `EEXIST` identifies contention directly; `EPERM` does so only when a fresh `lstat` confirms the lock path exists, covering Windows exclusive-create behavior without hiding an unrelated permission failure. The lock records its creator's PID and is removed by the holder in a `finally`; contention backs off exponentially and fails when the per-call `waitMs` deadline (default two seconds) passes.
84
+
85
+ ### Why the swap stays safe
86
+
87
+ - **Fresh inode, caller-stated mode** — the temp carries `mode` through the rename, so narrowing a wider-permission file has no chmod race. `mode` is required so the permission decision stays visible at every call site.
88
+ - **Readers never contend** — the rename commit is atomic, so a reader needs no lock.
89
+ - **A contender never deletes a lock** — age cannot distinguish a crashed owner from a paused live writer; recovery is an operator action.
90
+
91
+ </details>
92
+
93
+ -----
94
+
95
+ <a id="further-exploration"></a>
96
+ ## Further Exploration
97
+
98
+ Read these pages when you need the consuming stores or the family this primitive belongs to.
99
+
100
+ - [User-settings file store](../../settings/settings-file/README.md) — the settings document every write replaces through this package.
101
+ - [Credentials store](../../credentials/credentials-local/README.md) — the credentials file this package locks and replaces.
102
+ - [util group map](../README.md) — the zero-dependency utility family this package belongs to.
103
+
104
+ -----
105
+
106
+ <a id="model-experience"></a>
107
+ ## Model Experience
108
+
109
+ None, as this is a pure filesystem write primitive that registers nothing model-facing.
110
+
111
+ #### KV Cache effect
112
+
113
+ Nothing here enters a request prefix, so provider cache reuse is unaffected.
114
+
115
+ ## Known Limitations and Deferred Work
116
+
117
+ <a id="known-limitations-and-deferred-work"></a>
118
+
119
+
120
+ These limits define where the package is not the right tool. They are current package constraints, not a task backlog.
121
+
122
+ - **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
123
+ - **String content only** — no `Buffer` or stream form until a consumer needs one.
124
+ - **Orphaned locks require operator recovery** — a process that exits while holding the lock leaves the sibling behind; later writers time out without deleting it.
125
+
126
+ <a id="dev-note"></a>
127
+ ### Dev Note
128
+
129
+ <details>
130
+ <summary>Working context for maintainers — click to expand</summary>
131
+
132
+ A durability-replacement that `fsync`s the file and parent directory and preserves owner-only permissions on Windows remains open (tracked as `settings-atomic-durability` in source).
133
+
134
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,147 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ //#region lib/types/index.js
5
+ /**
6
+ * Zero-dependency atomic file replacement and writer coordination.
7
+ * `writeFileAtomic` writes a random-suffix sibling with exclusive create and
8
+ * the caller's permission bits, then renames it over the target, so readers
9
+ * observe either the old or the new complete content and a replaced file ends
10
+ * up with exactly the stated mode. `withFileLock` serializes cross-process
11
+ * writers of one file through a `wx`-created `<file>.lock` sibling, so a
12
+ * read-modify-write cycle can never resurrect a state another writer just
13
+ * replaced; readers stay lock-free because the rename commit is atomic.
14
+ * @module @worldapptechnologies/nulu-atomic-write
15
+ */
16
+ const WINDOWS_TRANSIENT_RENAME_ERRORS = new Set([
17
+ "EACCES",
18
+ "EBUSY",
19
+ "EPERM"
20
+ ]);
21
+ const WINDOWS_RENAME_RETRY_INITIAL_MS = 20;
22
+ const WINDOWS_RENAME_RETRY_MAX_MS = 200;
23
+ const WINDOWS_RENAME_RETRY_LIMIT = 8;
24
+ /** Whether Windows reported temporary interference with an atomic replacement. */
25
+ function isTransientWindowsRenameError(error) {
26
+ if (process.platform !== "win32") return false;
27
+ return WINDOWS_TRANSIENT_RENAME_ERRORS.has(error?.code ?? "");
28
+ }
29
+ /** Replace the target after bounded retries for transient Windows interference. */
30
+ async function renameAtomicTemp(temp, filename) {
31
+ let delay = WINDOWS_RENAME_RETRY_INITIAL_MS;
32
+ for (let retries = 0;; retries += 1) {
33
+ try {
34
+ await rename(temp, filename);
35
+ return;
36
+ } catch (error) {
37
+ if (!isTransientWindowsRenameError(error)) throw error;
38
+ if (retries >= WINDOWS_RENAME_RETRY_LIMIT) throw error;
39
+ }
40
+ await new Promise((resolve) => setTimeout(resolve, delay));
41
+ delay = Math.min(delay * 2, WINDOWS_RENAME_RETRY_MAX_MS);
42
+ }
43
+ }
44
+ /**
45
+ * Replace `filename` with `content` in one atomic step, creating parent
46
+ * directories. The content is first written to a random-suffix sibling opened
47
+ * with exclusive create (`wx`): the open refuses to follow a symlink planted
48
+ * at the temp path, and the fresh inode carries `options.mode` through the
49
+ * rename, so replacing a wider-permission file narrows it without a chmod
50
+ * race. The rename also replaces a symlinked target itself instead of writing
51
+ * through to its referent, and the same-directory sibling keeps the rename on
52
+ * one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`,
53
+ * and `EPERM` failures for a bounded interval while the complete temp file
54
+ * remains the rename source. On any remaining failure the temp file is
55
+ * removed and the failure rethrown. Crash durability (fsync) is out of scope.
56
+ * @param filename - final path receiving the content.
57
+ * @param content - complete next file content.
58
+ * @param options - permission bits for the replacement inode.
59
+ */
60
+ async function writeFileAtomic(filename, content, options) {
61
+ await mkdir(dirname(filename), {
62
+ recursive: true,
63
+ ...options.dirMode === void 0 ? {} : { mode: options.dirMode }
64
+ });
65
+ const temp = `${filename}.${randomBytes(6).toString("hex")}.tmp`;
66
+ try {
67
+ await writeFile(temp, content, {
68
+ mode: options.mode,
69
+ flag: "wx"
70
+ });
71
+ await renameAtomicTemp(temp, filename);
72
+ } catch (error) {
73
+ await rm(temp, { force: true });
74
+ throw error;
75
+ }
76
+ }
77
+ /** Whether an exclusive create found an existing lock. */
78
+ async function isLockContention(error, lockPath) {
79
+ const code = error?.code;
80
+ if (code === "EEXIST") return true;
81
+ if (code !== "EPERM") return false;
82
+ try {
83
+ await lstat(lockPath);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+ /**
90
+ * Retry cadence for a contended lock. These stay robustness invariants of the
91
+ * cross-process write protocol rather than deployment tunables: they govern how
92
+ * often a contender asks, which no caller has a reason to vary.
93
+ */
94
+ const LOCK_RETRY_INITIAL_MS = 20;
95
+ const LOCK_RETRY_MAX_MS = 200;
96
+ /**
97
+ * How long a contender waits when the caller states no limit — sized for the
98
+ * render-and-rename cycle every call site had when this package was written.
99
+ * Expiry fails the contender rather than guessing whether the existing lock
100
+ * still has an owner. How long is *worth* waiting is a property of the
101
+ * operation the lock holder runs, which is why {@link FileLockOptions.waitMs}
102
+ * exists; the value here is the floor for an operation that does file work
103
+ * alone.
104
+ */
105
+ const DEFAULT_LOCK_WAIT_MS = 2e3;
106
+ /**
107
+ * Hold the cross-process writer lock for `filename` around one operation. The
108
+ * lock is a `wx`-created sibling (`<filename>.lock`); paired with the
109
+ * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
110
+ * only writers contend. `EEXIST` is contention directly; an `EPERM` is
111
+ * contention only when a fresh `lstat` confirms the lock path exists, covering
112
+ * Windows exclusive-create behavior without hiding an unrelated permission
113
+ * failure. Contention backs off exponentially and fails with a timed-out error
114
+ * after the deadline. The contender never removes an existing lock because
115
+ * file age cannot prove that its owner stopped; orphan recovery is an operator
116
+ * action. The parent directory must exist.
117
+ * @param filename - the file whose writers this lock serializes.
118
+ * @param operation - the read-render-commit cycle to run while holding the lock.
119
+ * @param options - acquisition options; omitted waits {@link DEFAULT_LOCK_WAIT_MS}.
120
+ * @returns the operation's result; the lock releases on both outcomes.
121
+ */
122
+ async function withFileLock(filename, operation, options) {
123
+ const lockPath = `${filename}.lock`;
124
+ const deadline = Date.now() + (options?.waitMs ?? DEFAULT_LOCK_WAIT_MS);
125
+ let delay = LOCK_RETRY_INITIAL_MS;
126
+ for (;;) {
127
+ try {
128
+ await writeFile(lockPath, `${process.pid}\n`, {
129
+ mode: 384,
130
+ flag: "wx"
131
+ });
132
+ break;
133
+ } catch (error) {
134
+ if (!await isLockContention(error, lockPath)) throw error;
135
+ }
136
+ if (Date.now() >= deadline) throw new Error(`atomic-write: timed out waiting for the writer lock at ${lockPath}`);
137
+ await new Promise((resolve) => setTimeout(resolve, delay));
138
+ delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS);
139
+ }
140
+ try {
141
+ return await operation();
142
+ } finally {
143
+ await rm(lockPath, { force: true });
144
+ }
145
+ }
146
+ //#endregion
147
+ export { withFileLock, writeFileAtomic };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Zero-dependency atomic file replacement and writer coordination.
3
+ * `writeFileAtomic` writes a random-suffix sibling with exclusive create and
4
+ * the caller's permission bits, then renames it over the target, so readers
5
+ * observe either the old or the new complete content and a replaced file ends
6
+ * up with exactly the stated mode. `withFileLock` serializes cross-process
7
+ * writers of one file through a `wx`-created `<file>.lock` sibling, so a
8
+ * read-modify-write cycle can never resurrect a state another writer just
9
+ * replaced; readers stay lock-free because the rename commit is atomic.
10
+ * @module @worldapptechnologies/nulu-atomic-write
11
+ */
12
+ /**
13
+ * Filesystem options for {@link writeFileAtomic}; `mode` is required so the
14
+ * permission decision stays visible at every call site.
15
+ */
16
+ export interface WriteFileAtomicOptions {
17
+ /**
18
+ * Permission bits stamped on the fresh temp inode and carried through the
19
+ * rename (subject to the process umask, like every fresh inode).
20
+ */
21
+ mode: number;
22
+ /**
23
+ * Permission bits for parent directories this call creates (subject to the
24
+ * umask; existing directories keep their mode). Omission uses the mkdir
25
+ * default — pass `0o700` when the tree holds user-private data.
26
+ */
27
+ dirMode?: number;
28
+ }
29
+ /**
30
+ * Replace `filename` with `content` in one atomic step, creating parent
31
+ * directories. The content is first written to a random-suffix sibling opened
32
+ * with exclusive create (`wx`): the open refuses to follow a symlink planted
33
+ * at the temp path, and the fresh inode carries `options.mode` through the
34
+ * rename, so replacing a wider-permission file narrows it without a chmod
35
+ * race. The rename also replaces a symlinked target itself instead of writing
36
+ * through to its referent, and the same-directory sibling keeps the rename on
37
+ * one filesystem. Windows replacement retries transient `EACCES`, `EBUSY`,
38
+ * and `EPERM` failures for a bounded interval while the complete temp file
39
+ * remains the rename source. On any remaining failure the temp file is
40
+ * removed and the failure rethrown. Crash durability (fsync) is out of scope.
41
+ * @param filename - final path receiving the content.
42
+ * @param content - complete next file content.
43
+ * @param options - permission bits for the replacement inode.
44
+ */
45
+ export declare function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void>;
46
+ /** Options for one {@link withFileLock} acquisition. */
47
+ export interface FileLockOptions {
48
+ /**
49
+ * Maximum time to wait for the lock, in milliseconds. State one when the
50
+ * holder's operation legitimately runs longer than file work — a credential
51
+ * mutation that refreshes a token performs a network round trip while
52
+ * holding the lock, and leaving the default in place would fail every other
53
+ * writer of the same file for the duration. Waiting is productive: a
54
+ * contender that acquires the lock afterwards re-reads the committed state.
55
+ */
56
+ waitMs?: number;
57
+ }
58
+ /**
59
+ * Hold the cross-process writer lock for `filename` around one operation. The
60
+ * lock is a `wx`-created sibling (`<filename>.lock`); paired with the
61
+ * rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
62
+ * only writers contend. `EEXIST` is contention directly; an `EPERM` is
63
+ * contention only when a fresh `lstat` confirms the lock path exists, covering
64
+ * Windows exclusive-create behavior without hiding an unrelated permission
65
+ * failure. Contention backs off exponentially and fails with a timed-out error
66
+ * after the deadline. The contender never removes an existing lock because
67
+ * file age cannot prove that its owner stopped; orphan recovery is an operator
68
+ * action. The parent directory must exist.
69
+ * @param filename - the file whose writers this lock serializes.
70
+ * @param operation - the read-render-commit cycle to run while holding the lock.
71
+ * @param options - acquisition options; omitted waits {@link DEFAULT_LOCK_WAIT_MS}.
72
+ * @returns the operation's result; the lock releases on both outcomes.
73
+ */
74
+ export declare function withFileLock<T>(filename: string, operation: () => Promise<T>, options?: FileLockOptions): Promise<T>;
75
+ //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@worldapptechnologies/nulu-atomic-write",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)",
4
+ "version": "1.6.4",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/worldapptechnologies/nulu-harness.git",
11
+ "directory": "packages/util/atomic-write"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./src/*": "./src/*",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib/index.js",
26
+ "lib/types/**/*.d.ts"
27
+ ],
28
+ "license": "MIT",
29
+ "peerDependencies": {
30
+ "@worldapptechnologies/cordis": "^4.0.2"
31
+ },
32
+ "devDependencies": {
33
+ "@worldapptechnologies/cordis": "^4.0.2"
34
+ }
6
35
  }