mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
6908807236
This adds a config file that's loaded very early on during startup. It enables us to save/load settings from within Grist's admin panel, that affect the startup of the FlexServer. The config file loading: - Is type-safe, - Validates the config file on startup - Provides a path to upgrade to future versions. It should be extensible from other versions of Grist (such as desktop), by overriding `getGlobalConfig` in stubs. ---- Some minor refactors needed to occur to make this possible. This includes: - Extracting config loading into its own module (out of FlexServer). - Cleaning up the `loadConfig` function in FlexServer into `loadLoginSystem` (which is what its main purpose was before).
144 lines
4.2 KiB
TypeScript
144 lines
4.2 KiB
TypeScript
import * as fse from "fs-extra";
|
|
|
|
// Export dependencies for stubbing in tests.
|
|
export const Deps = {
|
|
readFile: fse.readFile,
|
|
writeFile: fse.writeFile,
|
|
pathExists: fse.pathExists,
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
};
|
|
}
|