diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts new file mode 100644 index 00000000000..431b2f4a01c --- /dev/null +++ b/apps/server/src/atomicWrite.ts @@ -0,0 +1,25 @@ +import { Effect, FileSystem, Path } from "effect"; +import * as Random from "effect/Random"; + +export const writeFileStringAtomically = (input: { + readonly filePath: string; + readonly contents: string; +}) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempFileId = yield* Random.nextUUIDv4; + const targetDirectory = path.dirname(input.filePath); + + yield* fs.makeDirectory(targetDirectory, { recursive: true }); + const tempDirectory = yield* fs.makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.filePath)}.`, + }); + const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`); + + yield* fs.writeFileString(tempPath, input.contents); + yield* fs.rename(tempPath, input.filePath); + }), + ); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 9689254c173..165b2edeb0c 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -46,6 +46,7 @@ import { } from "effect"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "./config.ts"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; type WhenToken = @@ -670,14 +671,17 @@ const makeKeybindings = Effect.gen(function* () { }); const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { - const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; - return Schema.encodeEffect(KeybindingsConfigPrettyJson)(rules).pipe( Effect.map((encoded) => `${encoded}\n`), - Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + Effect.flatMap((encoded) => + writeFileStringAtomically({ + filePath: keybindingsConfigPath, + contents: encoded, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + ), Effect.mapError( (cause) => new KeybindingsConfigError({ diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 369fca6218c..fdb31eecbed 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -1,6 +1,8 @@ import * as nodePath from "node:path"; import { type ServerProvider, ServerProvider as ServerProviderSchema } from "@t3tools/contracts"; -import { Cause, Effect, FileSystem, Path, Schema } from "effect"; +import { Cause, Effect, FileSystem, Schema } from "effect"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; export const PROVIDER_CACHE_IDS = [ "codex", @@ -96,22 +98,8 @@ export const readProviderStatusCache = (filePath: string) => export const writeProviderStatusCache = (input: { readonly filePath: string; readonly provider: ServerProvider; -}) => { - const tempPath = `${input.filePath}.${process.pid}.${Date.now()}.tmp`; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const encoded = `${JSON.stringify(input.provider, null, 2)}\n`; - - yield* fs.makeDirectory(path.dirname(input.filePath), { recursive: true }); - yield* fs.writeFileString(tempPath, encoded); - yield* fs.rename(tempPath, input.filePath); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true })); - }), - ), - ); -}; +}) => + writeFileStringAtomically({ + filePath: input.filePath, + contents: `${JSON.stringify(input.provider, null, 2)}\n`, + }); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 569e4ac1179..4b300f29c2c 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -1,5 +1,6 @@ -import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Effect, FileSystem, Option, Schema } from "effect"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { type ServerConfigShape } from "./config.ts"; import { formatHostForUrl, isWildcardHost } from "./startupAccess.ts"; @@ -42,15 +43,9 @@ export const persistServerRuntimeState = (input: { readonly path: string; readonly state: PersistedServerRuntimeState; }) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const pathService = yield* Path.Path; - const tempPath = `${input.path}.${process.pid}.${Date.now()}.tmp`; - return yield* fs.makeDirectory(pathService.dirname(input.path), { recursive: true }).pipe( - Effect.flatMap(() => fs.writeFileString(tempPath, `${JSON.stringify(input.state)}\n`)), - Effect.flatMap(() => fs.rename(tempPath, input.path)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), - ); + writeFileStringAtomically({ + filePath: input.path, + contents: `${JSON.stringify(input.state)}\n`, }); export const clearPersistedServerRuntimeState = (path: string) => diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index c47c442a86f..c2147a0f45b 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -39,6 +39,7 @@ import { Cause, } from "effect"; import * as Semaphore from "effect/Semaphore"; +import { writeFileStringAtomically } from "./atomicWrite.ts"; import { ServerConfig } from "./config.ts"; import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -233,14 +234,14 @@ const makeServerSettings = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); const writeSettingsAtomically = (settings: ServerSettings) => { - const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`; const sparseSettings = stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {}; - return Effect.succeed(`${JSON.stringify(sparseSettings, null, 2)}\n`).pipe( - Effect.tap(() => fs.makeDirectory(pathService.dirname(settingsPath), { recursive: true })), - Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), - Effect.flatMap(() => fs.rename(tempPath, settingsPath)), - Effect.ensuring(fs.remove(tempPath, { force: true }).pipe(Effect.ignore({ log: true }))), + return writeFileStringAtomically({ + filePath: settingsPath, + contents: `${JSON.stringify(sparseSettings, null, 2)}\n`, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, pathService), Effect.mapError( (cause) => new ServerSettingsError({