90 lines
3.9 KiB
JavaScript
90 lines
3.9 KiB
JavaScript
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]
|
|
})
|
|
}
|
|
}
|