import { readFile, open, rename, unlink } from 'node:fs/promises' import path from 'node:path' import { normalizeConfig, normalizeInstance, serializeConfig, immutableSnapshot } from './schema.js' import { ConfigNotFoundError, ConfigValidationError } from './errors.js' export async function atomicWriteJson(destination, value) { const temp = path.join(path.dirname(destination), `.${path.basename(destination)}.${process.pid}.${crypto.randomUUID()}.tmp`) try { const handle = await open(temp, 'wx', 0o600) try { await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`) await handle.sync() } finally { await handle.close() } await rename(temp, destination) try { const directory = await open(path.dirname(destination), 'r') try { await directory.sync() } finally { await directory.close() } } catch (error) { if (!['EINVAL', 'ENOTSUP', 'EISDIR', 'EPERM', 'EACCES'].includes(error.code)) throw error } } catch (error) { await unlink(temp).catch(() => {}) throw error } } export class FileConfigStore { #snapshot #queue = Promise.resolve() constructor({ configPath, examplePath, snapshot, atomicWrite = atomicWriteJson, env = process.env }) { this.configPath = configPath this.examplePath = examplePath this.atomicWrite = atomicWrite this.env = { ...env } this.#snapshot = immutableSnapshot({ ...snapshot, configPath }) } static async open({ configPath, examplePath, atomicWrite, env } = {}) { let text try { text = await readFile(configPath, 'utf8') } catch (error) { if (error.code !== 'ENOENT') throw error if (!examplePath) throw new ConfigNotFoundError(`config not found: ${configPath}`) text = await readFile(examplePath, 'utf8') } const effectiveEnv = env || process.env return new FileConfigStore({ configPath, examplePath, atomicWrite, env: effectiveEnv, snapshot: normalizeConfig(JSON.parse(text), effectiveEnv) }) } get snapshot() { return this.#snapshot } transact(mutator) { const operation = this.#queue.then(async () => { const draft = structuredClone(this.#snapshot) const result = await mutator(draft) const normalized = normalizeConfig(serializeConfig(draft), this.env) await this.atomicWrite(this.configPath, serializeConfig(normalized)) this.#snapshot = immutableSnapshot({ ...normalized, configPath: this.configPath }) return result?.id ? this.#snapshot.instances.find(item => item.id === result.id) || result : result }) this.#queue = operation.catch(() => {}) return operation } add(payload) { return this.transact(draft => { const next = normalizeInstance(payload, draft.instances.length) if (draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`) draft.instances.push(next) return next }) } update(id, payload) { return this.transact(draft => { const index = draft.instances.findIndex(item => item.id === id) if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`) const current = draft.instances[index] const merged = { ...current, ...payload, id: payload.id ? String(payload.id).trim() : current.id, auth: payload.auth !== undefined || payload.password !== undefined ? payload.auth || { password: payload.password || '' } : current.auth } const next = normalizeInstance(merged, index) if (next.id !== id && draft.instances.some(item => item.id === next.id)) throw new ConfigValidationError(`duplicate instance id: ${next.id}`) draft.instances[index] = next return next }) } delete(id) { return this.transact(draft => { const index = draft.instances.findIndex(item => item.id === id) if (index < 0) throw new ConfigNotFoundError(`instance not found: ${id}`) return draft.instances.splice(index, 1)[0] }) } }