From fafc9e027fb8289a17b6a252c7faeb9ee205199b Mon Sep 17 00:00:00 2001 From: Ansh Shakya Date: Wed, 12 Aug 2026 23:46:52 +0530 Subject: [PATCH] fix: replace steno with internal atomic writer to fix ENOENT race condition --- package-lock.json | 43 +------------ package.json | 3 - src/adapters/node/TextFile.ts | 112 +++++++++++++++++++++++++++++++--- 3 files changed, 105 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 460ecada..7425a82b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "lowdb", "version": "7.0.1", "license": "MIT", - "dependencies": { - "steno": "^4.0.2" - }, "devDependencies": { "@commitlint/cli": "^18.4.3", "@commitlint/config-conventional": "^18.4.3", @@ -28,8 +25,7 @@ "tempy": "^3.1.0", "ts-node": "^10.9.2", "tsx": "^4.7.0", - "typescript": "^5.3.3", - "xv": "^2.1.1" + "typescript": "^5.3.3" }, "engines": { "node": ">=18" @@ -4674,17 +4670,6 @@ "node": ">= 10.x" } }, - "node_modules/steno": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", - "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -5196,21 +5181,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, - "node_modules/xv": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/xv/-/xv-2.1.1.tgz", - "integrity": "sha512-OHT+1/gpxObzil0npzBS+OjlqzcHOAvUa6T2bgfHMv2iBMC33pqesqzEAx3buxMmAAT6XnehfLkL6a3Pi8bdCw==", - "dev": true, - "bin": { - "xv": "lib/bin.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8564,11 +8534,6 @@ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true }, - "steno": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", - "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==" - }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8926,12 +8891,6 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, - "xv": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/xv/-/xv-2.1.1.tgz", - "integrity": "sha512-OHT+1/gpxObzil0npzBS+OjlqzcHOAvUa6T2bgfHMv2iBMC33pqesqzEAx3buxMmAAT6XnehfLkL6a3Pi8bdCw==", - "dev": true - }, "y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index baffdfd7..bdacb096 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,6 @@ "postversion": "git push --follow-tags && npm publish", "prepare": "husky install" }, - "dependencies": { - "steno": "^4.0.2" - }, "devDependencies": { "@commitlint/cli": "^18.4.3", "@commitlint/config-conventional": "^18.4.3", diff --git a/src/adapters/node/TextFile.ts b/src/adapters/node/TextFile.ts index 00e626fc..2fc2505d 100644 --- a/src/adapters/node/TextFile.ts +++ b/src/adapters/node/TextFile.ts @@ -1,11 +1,109 @@ import { PathLike, readFileSync, renameSync, writeFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { readFile, rename, writeFile } from 'node:fs/promises' import path from 'node:path' - -import { Writer } from 'steno' +import { randomBytes } from 'node:crypto' import { Adapter, SyncAdapter } from '../../core/Low.js' +// Returns a temporary file +// Example: for /some/file will return /some/.file..tmp +function getTempFilename(file: PathLike): string { + const f = file.toString() + const randomStr = randomBytes(4).toString('hex') + return path.join(path.dirname(f), `.${path.basename(f)}.${randomStr}.tmp`) +} + +// Retries an asynchronous operation with a delay between retries and a maximum retry count +async function retryAsyncOperation( + fn: () => Promise, + maxRetries: number, + delayMs: number +): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + return await fn() + } catch (error) { + if (i < maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } else { + throw error // Rethrow the error if max retries reached + } + } + } +} + +class Writer { + #filename: string + #locked = false + #prev: [() => void, (err: Error) => void] | null = null + #next: [() => void, (err: Error) => void] | null = null + #nextPromise: Promise | null = null + #nextData: string | null = null + + constructor(filename: PathLike) { + this.#filename = filename.toString() + } + + // File is locked, add data for later + #add(data: string): Promise { + // Only keep most recent data + this.#nextData = data + + // Create a singleton promise to resolve all next promises once next data is written + this.#nextPromise ||= new Promise((resolve, reject) => { + this.#next = [resolve, reject] + }) + + // Return a promise that will resolve at the same time as next promise + return new Promise((resolve, reject) => { + this.#nextPromise?.then(resolve).catch(reject) + }) + } + + // File isn't locked, write data + async #write(data: string): Promise { + // Lock file + this.#locked = true + try { + // Atomic write + const tempFilename = getTempFilename(this.#filename) + await writeFile(tempFilename, data, 'utf-8') + await retryAsyncOperation( + async () => { + await rename(tempFilename, this.#filename) + }, + 10, + 100 + ) + + // Call resolve + this.#prev?.[0]() + } catch (err) { + // Call reject + if (err instanceof Error) { + this.#prev?.[1](err) + } + throw err + } finally { + // Unlock file + this.#locked = false + + this.#prev = this.#next + this.#next = this.#nextPromise = null + + if (this.#nextData !== null) { + const nextData = this.#nextData + this.#nextData = null + await this.write(nextData) + } + } + } + + async write(data: string): Promise { + return this.#locked ? this.#add(data) : this.#write(data) + } +} + export class TextFile implements Adapter { #filename: PathLike #writer: Writer @@ -36,13 +134,10 @@ export class TextFile implements Adapter { } export class TextFileSync implements SyncAdapter { - #tempFilename: PathLike #filename: PathLike constructor(filename: PathLike) { this.#filename = filename - const f = filename.toString() - this.#tempFilename = path.join(path.dirname(f), `.${path.basename(f)}.tmp`) } read(): string | null { @@ -61,7 +156,8 @@ export class TextFileSync implements SyncAdapter { } write(str: string): void { - writeFileSync(this.#tempFilename, str) - renameSync(this.#tempFilename, this.#filename) + const tempFilename = getTempFilename(this.#filename) + writeFileSync(tempFilename, str) + renameSync(tempFilename, this.#filename) } }