gristlabs_grist-core/app/server/lib/config.ts
Jordi Gutiérrez Hermoso 4013382170 config: replace fse read functions with sync variants
I need to be able to read the config at module load time, which makes
async difficult if not impossible.

This will make read config operations synchronous, which is fine. The
file is tiny and seldom read.
2024-07-18 14:32:46 -04:00

144 lines
4.3 KiB
TypeScript

import * as fse from "fs-extra";
// Export dependencies for stubbing in tests.
export const Deps = {
readFile: fse.readFileSync,
writeFile: fse.writeFile,
pathExists: fse.pathExistsSync,
};
/**
* Readonly config value - no write access.
*/
export interface IReadableConfigValue<T> {
get(): T;
}
/**
* Writeable config value. Write behaviour is asynchronous and defined by the implementation.
*/
export interface IWritableConfigValue<T> extends IReadableConfigValue<T> {
set(value: T): Promise<void>;
}
type FileContentsValidator<T> = (value: any) => T | null;
export class MissingConfigFileError extends Error {
public name: string = "MissingConfigFileError";
constructor(message: string) {
super(message);
}
}
export class ConfigValidationError extends Error {
public name: string = "ConfigValidationError";
constructor(message: string) {
super(message);
}
}
export interface ConfigAccessors<ValueType> {
get: () => ValueType,
set?: (value: ValueType) => Promise<void>
}
/**
* Provides type safe access to an underlying JSON file.
*
* Multiple FileConfigs for the same file shouldn't be used, as they risk going out of sync.
*/
export class FileConfig<FileContents> {
/**
* Creates a new type-safe FileConfig, by loading and checking the contents of the file with `validator`.
* @param configPath - Path to load.
* @param validator - Validates the contents are in the correct format, and converts to the correct type.
* Should throw an error or return null if not valid.
*/
public static async create<CreateConfigFileContents>(
configPath: string,
validator: FileContentsValidator<CreateConfigFileContents>
): Promise<FileConfig<CreateConfigFileContents>> {
// Start with empty object, as it can be upgraded to a full config.
let rawFileContents: any = {};
if (await Deps.pathExists(configPath)) {
rawFileContents = JSON.parse(await Deps.readFile(configPath, 'utf8'));
}
let fileContents = null;
try {
fileContents = validator(rawFileContents);
} catch (error) {
const configError =
new ConfigValidationError(`Config at ${configPath} failed validation: ${error.message}`);
configError.cause = error;
throw configError;
}
if (!fileContents) {
throw new ConfigValidationError(`Config at ${configPath} failed validation - check the format?`);
}
return new FileConfig<CreateConfigFileContents>(configPath, fileContents);
}
constructor(private _filePath: string, private _rawConfig: FileContents) {
}
public get<Key extends keyof FileContents>(key: Key): FileContents[Key] {
return this._rawConfig[key];
}
public async set<Key extends keyof FileContents>(key: Key, value: FileContents[Key]) {
this._rawConfig[key] = value;
await this.persistToDisk();
}
public async persistToDisk(): Promise<void> {
await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2));
}
}
/**
* Creates a function for creating accessors for a given key.
* Propagates undefined values, so if no file config is available, accessors are undefined.
* @param fileConfig - Config to load/save values to.
*/
export function fileConfigAccessorFactory<FileContents>(
fileConfig?: FileConfig<FileContents>
): <Key extends keyof FileContents>(key: Key) => ConfigAccessors<FileContents[Key]> | undefined
{
if (!fileConfig) { return (key) => undefined; }
return (key) => ({
get: () => fileConfig.get(key),
set: (value) => fileConfig.set(key, value)
});
}
/**
* Creates a config value optionally backed by persistent storage.
* Can be used as an in-memory value without persistent storage.
* @param defaultValue - Value to use if no persistent value is available.
* @param persistence - Accessors for saving/loading persistent value.
*/
export function createConfigValue<ValueType>(
defaultValue: ValueType,
persistence?: ConfigAccessors<ValueType> | ConfigAccessors<ValueType | undefined>,
): IWritableConfigValue<ValueType> {
let inMemoryValue = (persistence && persistence.get());
return {
get(): ValueType {
return inMemoryValue ?? defaultValue;
},
async set(value: ValueType) {
if (persistence && persistence.set) {
await persistence.set(value);
}
inMemoryValue = value;
}
};
}