Files
factorio-clusterio/packages/lib/src/config/classes.ts
T
024d7b427a Rename safeOutputFile to atomicOverwriteFile (#1167)
"safe" suggested the data being written survives a crash, it only
guarantees the old file is either kept or fully replaced. The new name
says what it does. No alias, plugin API breaks are allowed in minor
versions and the 2.1 migration guide lists the rename.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-11-01 23:14:31 +00:00

918 lines
27 KiB
TypeScript

// Configuration classes
import { Type, Static } from "@sinclair/typebox";
import * as fs from "node:fs/promises";
import events from "events";
import isDeepStrictEqual from "#is_deep_strict_equal";
import { basicType } from "../helpers.js";
import * as libSchema from "../schema.js";
import { StringEnum } from "../data/composites.js";
import { atomicOverwriteFile } from "../file_ops.js";
import * as validators from "./validators.js";
import { InvalidAccessError, InvalidFieldError, InvalidValueError } from "../errors.js";
import type { SyncHook } from "../SyncHook.js";
const ConfigLocation = StringEnum(["controller", "host", "control"]);
export type ConfigLocation = Static<typeof ConfigLocation>;
const OldConfigGroupSchema = Type.Object({
"name": Type.String(),
"fields": Type.Record(Type.String(), Type.Unknown()),
});
type OldConfigGroupSchema = Static<typeof OldConfigGroupSchema>;
const OldConfigSchema = Type.Object({
"groups": Type.Array(OldConfigGroupSchema),
});
export type OldConfigSchema = Static<typeof OldConfigSchema>;
export const ConfigSchema = Type.Record(Type.String(), Type.Union([
Type.Null(),
Type.Boolean(),
Type.Number(),
Type.String(),
Type.Object({}),
]));
export type ConfigSchema = Static<typeof ConfigSchema>;
type FieldValue = null | boolean | string | number | object;
type FieldType<T extends FieldValue> =
T extends boolean ? "boolean"
: T extends string ? "string"
: T extends number ? "number"
: T extends object ? "object"
: never;
export interface FieldDefinition<
Type extends FieldValue = any,
Fields extends Record<keyof Fields, FieldValue> = any,
> {
/**
* string declaring the type of the config value, supports boolean,
* string, number and object.
*/
type: FieldType<Type>;
/**
* Text used to identify the config field in user interfaces.
*/
title?: string;
/**
* Text used to describe the config field in user interfaces.
*/
description?: string;
/**
* Validation function ran to confirm the value is consistent with other config values.
* Should throw an error if the value in invalid / inconsistent
*/
validator?: (value: Type, config: Config<Fields>) => void;
/**
* Names the config fields that the validator depends on.
* This will ensure it is checked when these fields are changed.
*/
dependsOn?: string[];
/**
* Value to use for the autocomplete attribute in the Web UI's input element.
* Useful for making browsers behave properly with credential inputs.
* Only used for string and number fields.
*/
autoComplete?: string;
/**
* Web UI component to use for rendering this input. Plugins register components
* through the inputComponents hook on control.hooks in their web entrypoint.
*/
inputComponent?: string;
/**
* True if this field can be set to null. Defaults to false.
*/
optional?: Type extends null ? true : false;
/**
* Value this config field should take in a newly initialized config.
* This can also be a synchronous function returning the value to use.
* Promise results are not awaited.
*/
initialValue?: unknown | (() => unknown);
/**
* Array of all values that are acceptible. Useful for making
* enumerated values.
*/
enum?: unknown[];
/**
* True if a restart of the entity this config is attached to is
* required for changes to take effect. Informative only, defaults to
* false.
*/
restartRequired?: boolean;
/**
* Properties to invert the value of restartRequired for if present.
*/
restartRequiredProps?: string[];
/**
* Array of strings declaring where this config entry can be read and
* modified from.
*/
access?: ConfigLocation[];
/**
* If present treat the config value as a credential.
* Only the locations specified will be able to read the current value.
* Access can be used to specify which locations are able to overwrite the value.
*/
credential?: ConfigLocation[];
/**
* If present treat the config value as readonly.
* Only the locations specified will be able to update the config value.
* Access can be used to specify which locations are able to read the value.
*/
readonly?: ConfigLocation[];
/**
* True if graphical interfaces should hide the config value
*/
hidden?: boolean;
}
export type ConfigDefs<
Fields extends Record<keyof Fields, FieldValue>
> = {
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
};
function defaultValue(def: FieldDefinition) {
if (def.initialValue === undefined) {
return null;
}
if (typeof def.initialValue === "function") {
return def.initialValue();
}
return def.initialValue;
}
export type ConfigEvents = {
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
};
export enum ConfigAccess {
read = 0x1,
write = 0x2,
readWrite = 0x3,
}
/**
* Collection of config entries
*
* Type checks and stores a collection of related config entries. Attempt
* to set invalid values for fields will result in an error thrown while
* loading a config with invalid values will result in those being replaced
* by the innitial value for those fields.
*
* This is an EventEmitter with the following events:
* - fieldChanged -
* invoked after a field has been updated. Takes three parameters: name,
* curr, prev.
*
* @extends events.EventEmitter
*/
export class Config<
Fields extends Record<keyof Fields, FieldValue>
> extends events.EventEmitter<ConfigEvents> {
/**
* Mapping of config name to field meta data
* Note that mutating this object will lead to unexpected behaviour.
*/
declare static fieldDefinitions: ConfigDefs<any>;
declare ["constructor"]: typeof Config<Fields>;
/** A set of common validators and validator factories */
static validators = validators;
/**
* Handle migration between clusterio versions
*
* @param config - Input config read and validated from file
* @returns Output config to be loaded into the class
* @protected
*/
static migrations(config: Static<typeof this.jsonSchema>) {
return config;
}
/**
* Migrations attached by plugins, run after {@link Config.migrations}.
* Declared by each subclass that plugins can add fields to.
*/
declare static pluginMigrations?: SyncHook<ConfigSchema>;
/**
* Versions of the plugins whose migrations are attached, keyed by
* plugin name. Written to `<plugin>.version` after the migrations run.
*/
static pluginVersions = new Map<string, string>();
/** Defines the default access locations for the config class */
static defaultAccess: ConfigLocation[] = ["controller", "host", "control"];
/** Contains the fields and their values */
private fields: Fields;
private _unknownFields: Record<string, FieldValue> = {};
/** Set to true during validators to prevent edits */
private _readonly = false; // TODO replace with "using config.asReadonly()"
/** Set to true when a field in the config is changed. */
private _dirty = false;
/** Set to true when a 'restart required' field in the config is changed. */
restartRequired = false;
/** True if the config has changes that have not been saved. */
get dirty() {
return this._dirty;
}
/**
* Create a new instance of the given config
*
* @param location -
* Location to evaluate access for this instance from.
* @param fields -
* Serialized representation of fields to load.
* @param filepath -
* Filepath for the config to save to.
*/
constructor(
public location: ConfigLocation,
fields?: Static<typeof Config.jsonSchema>,
private _filepath?: string,
) {
if (typeof location !== "string") {
throw new Error("location must be a string");
}
super();
this.fields = Object.fromEntries(
Object.entries(
this.constructor.fieldDefinitions
).filter(
([name, def]) => this._checkAccess(name, def, location, ConfigAccess.read, false)
).map(
([name, def]) => [name, defaultValue(def)]
)
) as Fields;
if (fields) {
let migrated = this.constructor.migrations(fields);
if (this.constructor.pluginMigrations) {
migrated = this.constructor.pluginMigrations.reduce(migrated);
}
this.update(migrated, false, location);
}
for (const [pluginName, version] of this.constructor.pluginVersions) {
const name = `${pluginName}.version` as keyof Fields & string;
if (name in this.fields && this.fields[name] !== version) {
this.fields[name] = version as Fields[typeof name];
if (fields) {
this._dirty = true;
}
}
}
}
get filepath() {
return this._filepath;
}
set filepath(filepath: string | undefined) {
this._dirty = true;
this._filepath = filepath;
}
/** A staging version of the config with changes tracked, invalided by revertStaging */
private _staging?: { config: Config<Fields>, changes: Set<string>};
/** Get the latest version of staging */
// TODO replace with "using config.startStating()"
get staging(): Config<Fields> {
if (this._staging) { return this._staging.config; }
// Copied with update so migrations don't run again on the live fields
const config = new this.constructor(this.location);
config.update(this.fields, false);
const changes = new Set<string>();
config.on("fieldChanged", name => changes.add(name));
this._staging = { config, changes };
return config;
}
private static _fieldDependents: Map<string, {
name: string,
def: FieldDefinition & { validator: NonNullable<FieldDefinition["validator"]> }
}[]>;
/** Map of all fields whose validators depend on the given field */
static get fieldDependents(): typeof this._fieldDependents {
if (this._fieldDependents) {
return this._fieldDependents;
}
const dependents = new Map<string, { name: string, def: FieldDefinition }[]>();
for (const [name, def] of Object.entries(this.fieldDefinitions)) {
if (!def.validator || !def.dependsOn) { continue; }
for (const otherName of def.dependsOn) {
const fieldDependents = dependents.get(otherName);
if (fieldDependents) {
fieldDependents.push({name, def});
} else {
dependents.set(otherName, [{name, def}]);
}
}
}
// The type is correct because of "!def.validator", not clear why this doesn't infer
this._fieldDependents = dependents as any;
return this._fieldDependents;
}
/**
* Coerce and validate a value according to a field definition.
*
* @param name - Name of the field being validated.
* @param def - Definition describing the field type and constraints.
* @param value - Value to validate and coerce.
* @throws {InvalidValueError} if the value cannot be coerced to the correct type
* @returns The validated and coerced value.
* @private
*/
static _coerceValue<T extends FieldDefinition>(name: string, def: T, value: unknown) {
// Empty strings are treated as null
if (value === "") {
value = null;
}
// If def is an enum then it must be a value within it
if (def.enum && !def.enum.includes(value)) {
throw new InvalidValueError(`Expected one of [${def.enum.join(", ")}], not ${value}`);
}
// If it is a string, then attempt to coerce it
if (typeof value === "string") {
if (def.type === "boolean") {
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
const numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
if (numberRegExp.test(value.trim())) {
value = Number.parseFloat(value);
}
} else if (def.type === "object") {
try {
value = JSON.parse(value);
} catch (err: any) {
throw new InvalidValueError(`Error parsing value for ${name}: ${err.message}`);
}
}
}
// Check if it is allowed to be null
if (value === null) {
if (!def.optional) {
throw new InvalidValueError(`Field ${name} cannot be null`);
}
// Check if is the correct type
} else if (basicType(value) !== def.type) {
throw new InvalidValueError(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
return value as FieldType<T["type"]>;
}
/**
* Check access for a field
*
* @param name - Name of the field to check.
* @param def - definition for for the field to check.
* @param location - location to check access for.
* @param mode - type of access to check for.
* @param error - throw on failure to pass check.
* @throws {InvalidAccessError}
* if access is not granted and error is true.
* @returns true if access is granted
* @private
*/
_checkAccess(name: string, def: FieldDefinition, location: ConfigLocation, mode: ConfigAccess, error: boolean) {
// If credential array is present it acts as read permission and the access array as write permission,
// If readonly array is present it acts as write permission and the access array as the read permission.
// If neither is present access array acts as a combined read/write permission check.
if (
def.credential && mode & ConfigAccess.read && !(def.credential).includes(location)
|| def.readonly && mode & ConfigAccess.write && !(def.readonly).includes(location)
|| (
(!def.credential || mode & ConfigAccess.write)
&& (!def.readonly || mode & ConfigAccess.read)
&& !(def.access ?? this.constructor.defaultAccess).includes(location)
)
) {
if (error) {
throw new InvalidAccessError(`Field '${name}' is not accessible from ${location}`);
} else {
return false;
}
}
return true;
}
/**
* Set the value of a field, while also settings the required flags and events.
* This method does not perform any validation on the value being set
*
* @param name - Name of the field to set
* @param value - The new value of the field
* @param notify - False to skip setting dirty flags and emitting events
*/
_set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const prev = this.fields[name];
this.fields[name] = value as any;
this.revertStaging();
if (notify && !isDeepStrictEqual(value, prev)) {
const def = this.constructor.fieldDefinitions[name];
if (def.restartRequired) {
this.restartRequired = true;
}
this._dirty = true;
this.emit("fieldChanged", name, value, prev);
}
}
/**
* Create config from file
*
* @param location - Location used for access control.
* @param filepath - Filepath for the config to save to.
* @returns Instance of this config
*/
static async fromFile(location: ConfigLocation, filepath: string) {
const fields = JSON.parse(await fs.readFile(filepath, { encoding: "utf8" }));
return this.fromJSON(fields, location, filepath);
}
/**
* Saves this config to file if there are unsaved changes
* @param force - Set to true to save the file even if no changes have been made.
*/
async save(force = false) {
if (!force && !this._dirty) {
return;
}
if (!this.filepath) {
throw new Error("Cannot save config which has no filepath");
}
await atomicOverwriteFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 });
this._dirty = false;
}
static jsonSchema = ConfigSchema;
static validate = libSchema.compile(this.jsonSchema as any);
/**
* Create config from a serialized object
*
* @param json - Serialized config to load.
* @param location - Location used for access control.
* @param filepath - Filepath for the config to save to.
* @returns Instance of this config
*/
static fromJSON(
json: Static<typeof this.jsonSchema>,
location: ConfigLocation,
filepath?: string,
) {
// migrate: Pre alpha 14 config format
if (typeof json === "object" && json !== null && json.groups instanceof Array) {
json = Object.fromEntries(
(json.groups as unknown as OldConfigGroupSchema[]).flatMap(
group => Object.entries(group.fields).map(
([name, value]) => [`${group.name}.${name}`, value as FieldValue]
),
),
);
}
const valid = Config.validate(json);
if (!valid) {
throw new Error("Invalid config");
}
return new this(location, json, filepath);
}
toJSON() {
return this.toRemote(this.location);
}
/**
* Serialize the config to a plain JavaScript object
*
* @param remote - Location this serialised representation is for
* @param filter - When provided, only the given fields are serialized
* @returns JSON serializable representation of the config.
*/
toRemote(remote: ConfigLocation, filter?: (keyof Fields)[]): Static<typeof Config.jsonSchema> {
let fields: Record<string, FieldValue> = {};
for (let [name, value] of Object.entries(this.fields)) {
if (filter && !filter.includes(name as keyof Fields)) {
continue;
}
let def = this.constructor.fieldDefinitions[name];
if (
this.location !== "control" && !this._checkAccess(name, def, this.location, ConfigAccess.write, false)
|| this.location !== remote && !this._checkAccess(name, def, remote, ConfigAccess.read, false)
) {
continue;
}
fields[name] = value as FieldValue;
}
for (let [name, value] of Object.entries(this._unknownFields)) {
fields[name] = value;
}
return fields;
}
/**
* Update a config from a serialized config
*
* Overwrites the values of all the fields in this config that has a
* valid value in the passed serialized config with that value.
*
* @param json -
* Result from a previous call to .toRemote().
* @param notify - Invoke fieldChanged events if true.
* @param remote - Location this serialised representation was received from.
*/
update(
json: Static<typeof Config.jsonSchema>,
notify: boolean,
remote = this.location
) {
const valid = Config.validate(json);
if (!valid) {
throw new Error("Invalid config");
}
if (basicType(json) !== "object") {
throw new Error(`Expected object, not ${basicType(json)} for Config`);
}
for (let [name, value] of Object.entries(json) as [keyof Fields & string, FieldValue][]) {
let def = this.constructor.fieldDefinitions[name];
if (!def) {
this._unknownFields[name as string] = value;
continue;
}
if (
this.location !== "control" && !this._checkAccess(name, def, remote, ConfigAccess.write, false)
) {
continue;
}
if (def.optional) {
if (value !== null && basicType(value) !== def.type) {
continue;
}
} else if (basicType(value) !== def.type) {
continue;
}
if (def.enum && !def.enum.includes(value)) {
continue;
}
this._set(name, value, notify);
}
}
/**
* Check if field can be accessed from the given location
*
* @param name - Name of field to check.
* @param mode - Mode to access the field as.
* @param location - Location used for access control.
* @returns true if field is accessible
*/
canAccess(name: string, mode: ConfigAccess, location = this.location) {
if (typeof mode !== "number") {
throw new TypeError("mode argument is required to canAccess");
}
let def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidFieldError(`No field named '${name}'`);
}
return this._checkAccess(name, def, location, mode, false);
}
/**
* Get value for field
*
* @param name - Name of field to get.
* @param remote - Location this value is being read from.
* @returns Value stored for the field.
*/
get<Field extends keyof Fields & string>(name: Field, remote = this.location) {
let def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidFieldError(`No field named '${name}'`);
}
// If remote access is requested require it to be able to read the field
if (remote !== this.location) {
this._checkAccess(name, def, remote, ConfigAccess.read, true);
}
// Require that the field is either read or writable locally so that you can get the value
// back of write only fields recently written to.
if (
!this._checkAccess(name, def, this.location, ConfigAccess.write, false)
&& !this._checkAccess(name, def, this.location, ConfigAccess.read, false)
) {
throw new InvalidAccessError(`Field '${name}' is not accessible from ${this.location}`);
}
return this.fields[name];
}
/**
* Set value of field
*
* The field must be defined for the config and the value is
* type checked against the field definition.
*
* @param name - Name of field to set.
* @param newValue - Value to set for field.
* @param remote - Location this field is being set from.
* @throws {InvalidFieldError} if field is not defined.
* @throws {InvalidValueError} if value is not allowed for the field.
*/
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
const def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidFieldError(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
const value = Config._coerceValue(name, def, newValue);
const _readonly = this._readonly;
this.staging._readonly = true;
this._readonly = true;
try {
if (def.validator) {
try {
def.validator(value, this);
} catch (err: any) {
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
}
}
} finally {
this._readonly = _readonly;
this.staging._readonly = _readonly;
}
const dependents = this.constructor.fieldDependents.get(name);
if (dependents) {
// This mimics stage + commit but optimised for a single value
this.staging._set(name, value);
this.staging._readonly = true;
try {
for (const dependent of dependents) {
try {
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
} catch (err: any) {
throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`);
}
}
} finally {
this.revertStaging();
}
}
this._set(name, value);
}
/**
* Set property of object field
*
* Update value of stored object field by setting the specified property
* on it. The field must be defined as an object for the config.
*
* @param name - Name of field to set property on.
* @param prop - Name of property to set on field.
* @param value - the value to set the property to.
* @param remote - Location this property is being set from
* @throws {InvalidFieldError} if field is not defined.
* @throws {InvalidValueError} if field is not an object.
*/
setProp<Field extends keyof Fields & string>(
name: Field,
prop: string,
value?: unknown,
remote = this.location,
) {
let def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidFieldError(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
if (def.type !== "object") {
throw new InvalidValueError(`Cannot set property on non-object field '${name}'`);
}
let prev = this.fields[name] as Record<string, unknown>;
let updated = {...prev || {}};
if (value !== undefined) {
updated[prop] = value;
} else {
delete updated[prop];
}
const _readonly = this._readonly;
this.staging._readonly = true;
this._readonly = true;
try {
if (def.validator) {
try {
def.validator(updated, this);
} catch (err: any) {
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
}
}
} finally {
this._readonly = _readonly;
this.staging._readonly = _readonly;
}
const dependents = this.constructor.fieldDependents.get(name);
if (dependents) {
// This mimics stage + commit but optimised for a single value
this.staging._set(name, updated);
this.staging._readonly = true;
try {
for (const dependent of dependents) {
try {
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
} catch (err: any) {
throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`);
}
}
} finally {
this.revertStaging();
}
}
this._set(name, updated);
}
/**
* Similar to set but only validates the field type and not other constraints.
* The value is stored in staging and must be committed for the change to take effect.
*
* @param name - Name of field to set.
* @param newValue - Value to set for field.
* @param remote - Location this field is being set from.
* @throws {InvalidFieldError} if field is not defined.
* @throws {InvalidValueError} if value is not allowed for the field.
*/
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidFieldError(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
const value = Config._coerceValue(name, def, newValue);
this.staging._set(name, value);
}
/**
* Similar to setProp but only validates the field type and not other constraints.
* The value is stored in staging and must be committed for the change to take effect.
*
* @param name - Name of field to set property on.
* @param prop - Name of property to set on field.
* @param value - the value to set the property to.
* @param remote - Location this property is being set from
* @throws {InvalidFieldError} if field is not defined.
* @throws {InvalidValueError} if field is not an object.
*/
stageProp<Field extends keyof Fields & string>(
name: Field,
prop: string,
value?: unknown,
remote = this.location,
) {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidFieldError(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
if (def.type !== "object") {
throw new InvalidValueError(`Cannot set property on non-object field '${name}'`);
}
const prev = this.staging.get(name) as Record<string, unknown>;
const updated = {...prev || {}};
if (value !== undefined) {
updated[prop] = value;
} else {
delete updated[prop];
}
this.staging._set(name, updated);
}
/**
* Commit all changes in stating after they have been validated.
*
* @throws {InvalidValueError} if a value is not allowed for its field.
*/
commitStaging() {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const staging = this._staging;
if (!staging) {
return; // Nothing to commit
}
// Validate all fields using the current state of staging
const config = staging.config;
const _readonly = this._readonly;
config._readonly = true;
try {
for (const [fieldName, fieldDef] of Object.entries(this.constructor.fieldDefinitions)) {
if (fieldDef.validator) {
try {
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
} catch (err: any) {
throw new InvalidValueError(`Failed validation of ${fieldName}: ${err.message}`);
}
}
}
} finally {
config._readonly = _readonly;
}
// Validation success, set all values which have changed
for (const fieldName of staging.changes) {
const field = fieldName as keyof Fields & string;
this._set(field, config.fields[field]);
}
}
/** Revert all changes in staging */
revertStaging() {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
this._staging = undefined;
}
}