Rename safeOutputFile to atomicOverwriteFile (#1167)

"safe" suggested the data being written survives a crash, it only
guarantees the old file is either kept or fully replaced. The new name
says what it does. No alias, plugin API breaks are allowed in minor
versions and the 2.1 migration guide lists the rename.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-01 23:14:31 +00:00
1 parent 1b87497e15
commit 024d7b427a
19 files changed
+55 -49

No files matched your search

+6
View File
@@ -61,6 +61,12 @@ The `container` property of `WebPluginContext` is now the plugin's ES module nam
`lib.PluginWebpackEnvInfo` is renamed to `lib.PluginWebEnvInfo`.
## Renamed lib functions
`lib.safeOutputFile` is renamed to `lib.atomicOverwriteFile`.
It takes the same arguments.
## Host pairing
Host tokens are no longer generated by users.
+1 -1
View File
@@ -209,7 +209,7 @@ async function handleBootstrapCommand(
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await lib.safeOutputFile(args.output, content, { mode: 0o600 });
await lib.atomicOverwriteFile(args.output, content, { mode: 0o600 });
}
}
}
+1 -1
View File
@@ -138,7 +138,7 @@ export default class Controller {
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
try {
const releases = await lib.fetchLatestReleases();
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
await lib.atomicOverwriteFile(cachePath, JSON.stringify(releases, null, "\t"));
return releases;
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
+2 -2
View File
@@ -40,8 +40,8 @@ export default class UserManager {
}
// We intentionally write the roles first because if it fails then the old user file will still contain them
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
await lib.atomicOverwriteFile(rolesFilePath, JSON.stringify(combinedJson.roles));
await lib.atomicOverwriteFile(usersFilePath, JSON.stringify(combinedJson.users));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
+1 -1
View File
@@ -384,7 +384,7 @@ async function uploadExport(req: Request, res: Response) {
let { name, ext } = path.posix.parse(filePath);
let hash = await lib.hashStream(await zip.openStream(file));
assets[name] = `${name}.${hash}${ext}`;
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), content);
await lib.atomicOverwriteFile(path.join("static", `${name}.${hash}${ext}`), content);
}
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
+11 -11
View File
@@ -104,7 +104,7 @@ async function pathExists(filePath) {
return true;
}
async function safeOutputFile(file, data, options={}) {
async function atomicOverwriteFile(file, data, options={}) {
let { dir, name, ext } = path.parse(file);
let temporary = path.join(dir, `${name}.tmp${ext}`);
if (dir) {
@@ -257,7 +257,7 @@ async function migrateRename(args) {
async function migrateConfig(source, destination) {
if (await pathExists(source) && !await pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
await atomicOverwriteFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
), { mode: 0o600 });
logger.info(`Migrated ${source} to ${destination}`);
@@ -269,7 +269,7 @@ async function migrateRename(args) {
let instancesFile = path.join("database", "instances.json");
if (await pathExists(instancesFile)) {
await safeOutputFile(instancesFile, JSON.stringify(
await atomicOverwriteFile(instancesFile, JSON.stringify(
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
));
logger.info(`Migrated ${instancesFile}`);
@@ -281,7 +281,7 @@ async function migrateRename(args) {
for (let role of users["roles"]) {
role["permissions"] = role["permissions"].map(rename);
}
await safeOutputFile(usersFile, JSON.stringify(users, null, "\t"));
await atomicOverwriteFile(usersFile, JSON.stringify(users, null, "\t"));
logger.info(`Migrated ${usersFile}`);
}
@@ -309,7 +309,7 @@ async function migrateRename(args) {
lines.push(inputLine);
}
}
safeOutputFile(outputFile, Buffer.concat(lines));
atomicOverwriteFile(outputFile, Buffer.concat(lines));
}
async function migrateLogsDir(inputLogs, outputLogs) {
@@ -434,7 +434,7 @@ async function groupIdToName(gid) {
async function writeScripts(mode, selfSignedCert) {
if (["standalone", "controller"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
await atomicOverwriteFile(
"run-controller.cmd",
`\
@echo off
@@ -447,7 +447,7 @@ goto restart
`
);
} else {
await safeOutputFile(
await atomicOverwriteFile(
"run-controller.sh",
`\
#!/bin/bash
@@ -459,7 +459,7 @@ done
`,
{ mode: 0o755 },
);
await safeOutputFile(
await atomicOverwriteFile(
"systemd/clusteriocontroller.service",
`\
[Unit]
@@ -486,7 +486,7 @@ WantedBy=multi-user.target
if (["standalone", "host"].includes(mode)) {
if (process.platform === "win32") {
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
await atomicOverwriteFile(
"run-host.cmd",
`\
@echo off
@@ -500,7 +500,7 @@ goto restart
);
} else {
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
await atomicOverwriteFile(
"run-host.sh",
`\
#!/bin/bash
@@ -513,7 +513,7 @@ done
{ mode: 0o755 },
);
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
await safeOutputFile(
await atomicOverwriteFile(
"systemd/clusteriohost.service",
`\
[Unit]
+5 -5
View File
@@ -693,7 +693,7 @@ end`.replace(/\r?\n/g, " ");
players: [...this.playerStats],
player_autosave_slot: this._playerAutosaveSlot,
}, null, "\t");
await lib.safeOutputFile(this.path("instance-stats.json"), content);
await lib.atomicOverwriteFile(this.path("instance-stats.json"), content);
}
/**
@@ -870,7 +870,7 @@ end`.replace(/\r?\n/g, " ");
"_comment_warning": warning,
...await this.resolveServerSettings(this.config.get("factorio.settings"), includeCredentials),
};
await lib.safeOutputFile(
await lib.atomicOverwriteFile(
this.server.writePath("server-settings.json"),
JSON.stringify(serverSettings, null, "\t")
);
@@ -996,7 +996,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
this.logger.verbose("Writing server-adminlist.json");
await lib.safeOutputFile(
await lib.atomicOverwriteFile(
this.server.writePath("server-adminlist.json"),
JSON.stringify([...this._host.adminlist], null, "\t")
);
@@ -1004,7 +1004,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_banlist") !== "disabled") {
this.logger.verbose("Writing server-banlist.json");
await lib.safeOutputFile(
await lib.atomicOverwriteFile(
this.server.writePath("server-banlist.json"),
JSON.stringify([...this._host.banlist].map(
([username, reason]) => ({ username, reason })
@@ -1014,7 +1014,7 @@ end`.replace(/\r?\n/g, " ");
if (this.config.get("factorio.sync_whitelist") !== "disabled") {
this.logger.verbose("Writing server-whitelist.json");
await lib.safeOutputFile(
await lib.atomicOverwriteFile(
this.server.writePath("server-whitelist.json"),
JSON.stringify([...this._host.whitelist], null, "\t")
);
+2 -2
View File
@@ -3,7 +3,7 @@ import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging.js";
import { downloadFile, safeOutputFile } from "./file_ops.js";
import { downloadFile, atomicOverwriteFile } from "./file_ops.js";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod.js";
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data/index.js";
@@ -148,7 +148,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
if (files.size) {
logger.info(`Loaded info for ${files.size} mods`);
const content: ModInfoCache = [...files.values()].map(mod => mod.toJSON());
await safeOutputFile(cacheFilename, JSON.stringify(content));
await atomicOverwriteFile(cacheFilename, JSON.stringify(content));
}
return new this(modsDirectory, files);
+2 -2
View File
@@ -7,7 +7,7 @@ import isDeepStrictEqual from "#is_deep_strict_equal";
import { basicType } from "../helpers.js";
import * as libSchema from "../schema.js";
import { StringEnum } from "../data/composites.js";
import { safeOutputFile } from "../file_ops.js";
import { atomicOverwriteFile } from "../file_ops.js";
import * as validators from "./validators.js";
import { InvalidAccessError, InvalidFieldError, InvalidValueError } from "../errors.js";
import type { SyncHook } from "../SyncHook.js";
@@ -475,7 +475,7 @@ export class Config<
if (!this.filepath) {
throw new Error("Cannot save config which has no filepath");
}
await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 });
await atomicOverwriteFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 });
this._dirty = false;
}
+2 -2
View File
@@ -79,7 +79,7 @@ export async function loadJsonAsMap(filePath: string): Promise<Map<string, unkno
*/
export async function saveMapAsJson(filePath: string, map: Map<string, unknown>) {
let obj = mapToObject(map);
await libFileOps.safeOutputFile(filePath, JSON.stringify(obj, null, "\t"));
await libFileOps.atomicOverwriteFile(filePath, JSON.stringify(obj, null, "\t"));
}
/**
@@ -139,7 +139,7 @@ export async function loadJsonArrayAsMap(filePath: string): Promise<Map<unknown,
* @throws {Error} if an error occured writing to the file.
*/
export async function saveMapAsJsonArray(filePath: string, map: Map<unknown, { id: unknown }>) {
await libFileOps.safeOutputFile(filePath, JSON.stringify([...map.values()], null, "\t"));
await libFileOps.atomicOverwriteFile(filePath, JSON.stringify([...map.values()], null, "\t"));
}
+5 -5
View File
@@ -2,7 +2,7 @@ import path from "path";
import fs from "node:fs/promises";
import { EventEmitter } from "events";
import { safeOutputFile } from "./file_ops.js";
import { atomicOverwriteFile } from "./file_ops.js";
import { SubscribableValue } from "./subscriptions.js";
import { logger } from "./logging.js";
import { ControllerConfig } from "./config/index.js";
@@ -56,7 +56,7 @@ export class JsonDatastoreProvider<
// Save the data to the json file
async save(data: Map<string, V>) {
logger.verbose(`Saving ${this.filePath}`);
await safeOutputFile(this.filePath, JSON.stringify(Object.fromEntries(data), null, "\t"));
await atomicOverwriteFile(this.filePath, JSON.stringify(Object.fromEntries(data), null, "\t"));
}
// Load the data from the json file
@@ -91,7 +91,7 @@ export class JsonDatastoreProvider<
logger.warn(
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
);
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
await atomicOverwriteFile(backupPath, JSON.stringify(rawJson, null, "\t"));
}
return result;
@@ -115,7 +115,7 @@ export class JsonIdDatastoreProvider<
// Save the data to the json file
async save(data: Map<V["id"], V>) {
logger.verbose(`Saving ${this.filePath}`);
await safeOutputFile(this.filePath, JSON.stringify([...data.values()], null, "\t"));
await atomicOverwriteFile(this.filePath, JSON.stringify([...data.values()], null, "\t"));
}
// Load the data from the json file
@@ -150,7 +150,7 @@ export class JsonIdDatastoreProvider<
logger.warn(
`Duplicates detected in ${this.filePath}, a backup was written to: ${backupPath}`
);
await safeOutputFile(backupPath, JSON.stringify(rawJson, null, "\t"));
await atomicOverwriteFile(backupPath, JSON.stringify(rawJson, null, "\t"));
}
return result;
+2 -2
View File
@@ -89,7 +89,7 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
}
/**
* Safely overwrite file data
* Atomically overwrite file data
*
* Writes the passed data to a temporary file and rename it over the target
* file. If the operation fails because the parent directory does not exist, it
@@ -105,7 +105,7 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
* @param data - Content to write.
* @param options - see fs.writeFile, `flag` and `flush` may not be used.
*/
export async function safeOutputFile(
export async function atomicOverwriteFile(
file: string,
data: string | Buffer,
options?: Omit<
+1 -1
View File
@@ -328,7 +328,7 @@ export async function loadPluginList(
hasChanges = await findNpmPlugins(pluginList, skippedNpmPlugins) || hasChanges;
if (hasChanges) {
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
await libFileOps.atomicOverwriteFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
}
return pluginList;
+1 -1
View File
@@ -312,7 +312,7 @@ export class LogIndex {
* was loaded/created from.
*/
async save() {
await libFileOps.safeOutputFile(
await libFileOps.atomicOverwriteFile(
path.join(this.logDirectory, "index.json"),
JSON.stringify(this.serialize(), null, "\t")
);
+2 -2
View File
@@ -199,7 +199,7 @@ export async function handlePluginCommand(
}
pluginList.set(pluginInfo.name, pluginPath);
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
await libFileOps.atomicOverwriteFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
print(`Added ${pluginInfo.name}`);
} else if (command === "remove") {
@@ -209,7 +209,7 @@ export async function handlePluginCommand(
return;
}
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
await libFileOps.atomicOverwriteFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
print(`Removed ${args.name}`);
} else if (command === "list") {
+1 -1
View File
@@ -63,7 +63,7 @@ async function saveDatabase(
let file = path.resolve(controllerConfig.get("controller.database_directory"), "player_sync.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify([...playerDatastore, ...tombstones]);
await lib.safeOutputFile(file, content);
await lib.atomicOverwriteFile(file, content);
}
export default async function(context: ControllerPluginContext) {
+2 -2
View File
@@ -49,7 +49,7 @@ async function saveTechnologies(
) {
let filePath = path.join(controllerConfig.get("controller.database_directory"), "technologies.json");
logger.verbose(`writing ${filePath}`);
await lib.safeOutputFile(filePath, JSON.stringify([...technologies.entries()], null, "\t"));
await lib.atomicOverwriteFile(filePath, JSON.stringify([...technologies.entries()], null, "\t"));
}
const MAX_QUEUE = 7;
@@ -71,7 +71,7 @@ async function loadQueue(controllerConfig: lib.ControllerConfig, logger: lib.Log
async function saveQueue(controllerConfig: lib.ControllerConfig, queue: string[], logger: lib.Logger) {
let filePath = path.join(controllerConfig.get("controller.database_directory"), "research_queue.json");
logger.verbose(`writing ${filePath}`);
await lib.safeOutputFile(filePath, JSON.stringify(queue, null, "\t"));
await lib.atomicOverwriteFile(filePath, JSON.stringify(queue, null, "\t"));
}
export default async function(context: ControllerPluginContext) {
+1 -1
View File
@@ -64,7 +64,7 @@ async function saveDatabase(
let file = path.resolve(controllerConfig.get("controller.database_directory"), "items.json");
logger.verbose(`writing ${file}`);
let content = JSON.stringify(items.serialize());
await lib.safeOutputFile(file, content);
await lib.atomicOverwriteFile(file, content);
} else if (items) {
logger.error(`Item database too large, not saving (${items.size})`);
}
+7 -7
View File
@@ -51,7 +51,7 @@ describe("lib/file_ops", function() {
});
});
describe("safeOutputFile()", function() {
describe("atomicOverwriteFile()", function() {
async function assertNoTemporary(target) {
let { dir, name } = path.parse(target);
let entries = (await fs.readdir(dir)).filter(
@@ -61,14 +61,14 @@ describe("lib/file_ops", function() {
}
it("should write new target file", async function() {
let target = path.join(baseDir, "safe", "simple.txt");
await lib.safeOutputFile(target, "a text file", "utf8");
await lib.atomicOverwriteFile(target, "a text file", "utf8");
await assertNoTemporary(target);
assert.equal(await fs.readFile(target, "utf8"), "a text file");
});
it("should overwrite existing target file", async function() {
let target = path.join(baseDir, "safe", "exists.txt");
await fs.writeFile(target, "previous", "utf8");
await lib.safeOutputFile(target, "current", "utf8");
await lib.atomicOverwriteFile(target, "current", "utf8");
await assertNoTemporary(target);
assert.equal(await fs.readFile(target, "utf8"), "current");
});
@@ -77,7 +77,7 @@ describe("lib/file_ops", function() {
this.skip();
}
let target = path.join(baseDir, "safe", "mode.txt");
await lib.safeOutputFile(target, "private", { mode: 0o600 });
await lib.atomicOverwriteFile(target, "private", { mode: 0o600 });
assert.equal((await fs.stat(target)).mode & 0o777, 0o600);
assert.equal(await fs.readFile(target, "utf8"), "private");
});
@@ -85,7 +85,7 @@ describe("lib/file_ops", function() {
let target = path.join(baseDir, "safe", "concurrent.txt");
let writes = [];
for (let i = 0; i < 20; i++) {
writes.push(lib.safeOutputFile(target, `write ${i}`, "utf8"));
writes.push(lib.atomicOverwriteFile(target, `write ${i}`, "utf8"));
}
// Ignore errors from concurrently renaming onto the same file.
await Promise.allSettled(writes);
@@ -95,13 +95,13 @@ describe("lib/file_ops", function() {
it("should remove the temporary file when the rename fails", async function() {
let target = path.join(baseDir, "safe", "rename-fails");
await fs.mkdir(target, { recursive: true });
await assert.rejects(lib.safeOutputFile(target, "data", "utf8"));
await assert.rejects(lib.atomicOverwriteFile(target, "data", "utf8"));
await assertNoTemporary(target);
});
it("should handle creating file in current working directory", async function() {
let target = "temporary-file-made-to-test-cwd.txt";
try {
await lib.safeOutputFile(target, "a text file", "utf8");
await lib.atomicOverwriteFile(target, "a text file", "utf8");
} finally {
try {
await fs.unlink(target);