|
| 1 | +import { |
| 2 | + type DeepPartial, |
| 3 | + type GlobalConfig, |
| 4 | + type GlobalConfigAccessor, |
| 5 | + type GlobalConfigFileData, |
| 6 | + type ReadWriteJson, |
| 7 | +} from "./types"; |
| 8 | +import type { Logger } from "../logging"; |
| 9 | +import z from "zod"; |
| 10 | +import { globalConfigFileSchema } from "./types"; |
| 11 | +import { getDefaultGlobalConfig, resolveConfig } from "./config"; |
| 12 | + |
| 13 | +type DefaultGlobalConfigAccessorConfig = { |
| 14 | + logger: Logger; |
| 15 | + json: ReadWriteJson; |
| 16 | + filePath: string; |
| 17 | +}; |
| 18 | + |
| 19 | +/** |
| 20 | + * Implements {@link GlobalConfigAccessor} accepting overrides from the given file. |
| 21 | + * |
| 22 | + * @param config - The logger and json datasource to be used by the config. . |
| 23 | + * @returns A {@link GlobalConfigAccessor} instance. |
| 24 | + */ |
| 25 | +export class DefaultGlobalConfigAccessor implements GlobalConfigAccessor { |
| 26 | + private cachedConfig: GlobalConfig | undefined; |
| 27 | + private readonly json: ReadWriteJson; |
| 28 | + private readonly filePath: string; |
| 29 | + private readonly logger: Logger; |
| 30 | + |
| 31 | + constructor(config: DefaultGlobalConfigAccessorConfig) { |
| 32 | + this.json = config.json; |
| 33 | + this.filePath = config.filePath; |
| 34 | + this.logger = config.logger.child({ configFilePath: this.filePath }); |
| 35 | + this.logger.info(`creating global config accessor`); |
| 36 | + } |
| 37 | + |
| 38 | + public async get(): Promise<GlobalConfig> { |
| 39 | + const logger = this.logger.child({ method: "get" }); |
| 40 | + logger.debug("reading global config"); |
| 41 | + |
| 42 | + if (this.cachedConfig) return this.cachedConfig; |
| 43 | + logger.debug("no config cached, reading from source"); |
| 44 | + |
| 45 | + const configFileData = await this.readConfigFile(); |
| 46 | + |
| 47 | + // if no installationId is present, generate one and merge it into the file data |
| 48 | + if (!configFileData.installationId) { |
| 49 | + configFileData.installationId = getDefaultGlobalConfig().installationId; |
| 50 | + this.logger.info(`no installationId found, persisting one`); |
| 51 | + |
| 52 | + try { |
| 53 | + await this.writeToConfigFile(configFileData); |
| 54 | + } catch (e) { |
| 55 | + const error = e instanceof Error ? e : new Error(String(e)); |
| 56 | + this.logger |
| 57 | + .child({ errorName: error.name, errorMessage: error.message }) |
| 58 | + .warn(`failed to write initial config file data`); |
| 59 | + // best effort |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + this.cachedConfig = resolveConfig(configFileData); |
| 64 | + return this.cachedConfig; |
| 65 | + } |
| 66 | + |
| 67 | + public async set(newConfig: GlobalConfig): Promise<GlobalConfig> { |
| 68 | + this.logger.child({ newConfig, method: "set" }).debug("writing global config"); |
| 69 | + |
| 70 | + const configDiff = diff(newConfig, resolveConfig({})); |
| 71 | + await this.writeToConfigFile(configDiff); |
| 72 | + this.cachedConfig = newConfig; |
| 73 | + return this.cachedConfig; |
| 74 | + } |
| 75 | + |
| 76 | + private async writeToConfigFile(data: GlobalConfigFileData): Promise<GlobalConfigFileData> { |
| 77 | + const dataParseResult = globalConfigFileSchema.safeParse(data); |
| 78 | + if (!dataParseResult.success) { |
| 79 | + // TODO: mark this as a client-source error. |
| 80 | + throw new TypeError(z.prettifyError(dataParseResult.error)); |
| 81 | + } |
| 82 | + |
| 83 | + await this.json.write(this.filePath, dataParseResult.data); |
| 84 | + return data; |
| 85 | + } |
| 86 | + |
| 87 | + private async readConfigFile(): Promise<GlobalConfigFileData> { |
| 88 | + try { |
| 89 | + return await this.json.read(this.filePath, globalConfigFileSchema); |
| 90 | + } catch (e) { |
| 91 | + if (isFileNotFoundError(e)) return {}; |
| 92 | + |
| 93 | + const error = e instanceof Error ? e : new Error(String(e)); |
| 94 | + this.logger |
| 95 | + .child({ errorName: error.name, errorMessage: error.message }) |
| 96 | + .warn(`failed to read global config file`); |
| 97 | + throw e; |
| 98 | + } |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +function isFileNotFoundError(e: unknown): boolean { |
| 103 | + return e instanceof Error && "code" in e && e.code === "ENOENT"; |
| 104 | +} |
| 105 | + |
| 106 | +/** Recursively diffs two objects, comparing leaf values by reference equality. Returns only the fields in a that differ from b */ |
| 107 | +function diff<T extends Record<string, unknown>>(a: T, b: T): DeepPartial<T> { |
| 108 | + const result: Record<string, unknown> = {}; |
| 109 | + |
| 110 | + for (const key of Object.keys(a)) { |
| 111 | + const aVal = a[key]; |
| 112 | + const bVal = b[key]; |
| 113 | + |
| 114 | + if (isRecord(aVal) && isRecord(bVal)) { |
| 115 | + const nested = diff(aVal, bVal); |
| 116 | + if (Object.keys(nested).length > 0) { |
| 117 | + result[key] = nested; |
| 118 | + } |
| 119 | + } else if (aVal !== bVal) { |
| 120 | + result[key] = aVal; |
| 121 | + } |
| 122 | + } |
| 123 | + |
| 124 | + return result as DeepPartial<T>; |
| 125 | +} |
| 126 | + |
| 127 | +function isRecord(value: unknown): value is Record<string, unknown> { |
| 128 | + return typeof value === "object" && value !== null && !Array.isArray(value); |
| 129 | +} |
0 commit comments