import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es"; import { Default, type Static, type TObject, getFullPathKeys, mark, parse, stripMark } from "../utils"; export type SchemaObjectOptions = { onUpdate?: (config: Static) => void | Promise; onBeforeUpdate?: ( from: Static, to: Static ) => Static | Promise>; restrictPaths?: string[]; overwritePaths?: (RegExp | string)[]; forceParse?: boolean; }; export class SchemaObject { private readonly _default: Partial>; private _value: Static; private _config: Static; private _restriction_bypass: boolean = false; constructor( private _schema: Schema, initial?: Partial>, private options?: SchemaObjectOptions ) { this._default = Default(_schema, {} as any) as any; this._value = initial ? parse(_schema, cloneDeep(initial as any), { forceParse: this.isForceParse(), skipMark: this.isForceParse() }) : this._default; this._config = Object.freeze(this._value); } protected isForceParse(): boolean { return this.options?.forceParse ?? true; } default(): Static { return this._default; } private async onBeforeUpdate(from: Static, to: Static): Promise> { if (this.options?.onBeforeUpdate) { return this.options.onBeforeUpdate(from, to); } return to; } get(options?: { stripMark?: boolean }): Static { if (options?.stripMark) { return stripMark(this._config); } return this._config; } async set(config: Static, noEmit?: boolean): Promise> { const valid = parse(this._schema, cloneDeep(config) as any, { forceParse: true, skipMark: this.isForceParse() }); // regardless of "noEmit" – this should always be triggered const updatedConfig = await this.onBeforeUpdate(this._config, valid); this._value = updatedConfig; this._config = Object.freeze(updatedConfig); if (noEmit !== true) { await this.options?.onUpdate?.(this._config); } return this._config; } bypass() { this._restriction_bypass = true; return this; } throwIfRestricted(object: object): void; throwIfRestricted(path: string): void; throwIfRestricted(pathOrObject: string | object): void { // only bypass once if (this._restriction_bypass) { this._restriction_bypass = false; return; } const paths = this.options?.restrictPaths ?? []; if (Array.isArray(paths) && paths.length > 0) { for (const path of paths) { const restricted = typeof pathOrObject === "string" ? pathOrObject.startsWith(path) : has(pathOrObject, path); if (restricted) { throw new Error(`Path "${path}" is restricted`); } } } return; } async patch(path: string, value: any): Promise<[Partial>, Static]> { const current = cloneDeep(this._config); const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); //console.log(getFullPathKeys(value).map((k) => path + "." + k)); // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = mergeWith(current, partial, (objValue, srcValue) => { if (Array.isArray(objValue) && Array.isArray(srcValue)) { return srcValue; } }); //console.log("overwritePaths", this.options?.overwritePaths); if (this.options?.overwritePaths) { const keys = getFullPathKeys(value).map((k) => { // only prepend path if given return path.length > 0 ? path + "." + k : k; }); const overwritePaths = keys.filter((k) => { return this.options?.overwritePaths?.some((p) => { if (typeof p === "string") { return k === p; } else { return p.test(k); } }); }); //console.log("overwritePaths", keys, overwritePaths); if (overwritePaths.length > 0) { // filter out less specific paths (but only if more than 1) const specific = overwritePaths.length > 1 ? overwritePaths.filter((k) => overwritePaths.some((k2) => { //console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); return k2 !== k && k2.startsWith(k); }) ) : overwritePaths; //console.log("specific", specific); for (const p of specific) { set(config, p, get(partial, p)); } } } //console.log("patch", { path, value, partial, config, current }); const newConfig = await this.set(config); return [partial, newConfig]; } async overwrite(path: string, value: any): Promise<[Partial>, Static]> { const current = cloneDeep(this._config); const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); //console.log(getFullPathKeys(value).map((k) => path + "." + k)); // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = set(current, path, value); //console.log("overwrite", { path, value, partial, config, current }); const newConfig = await this.set(config); return [partial, newConfig]; } has(path: string): boolean { const p = path.split("."); if (p.length > 1) { const parent = p.slice(0, -1).join("."); if (!has(this._config, parent)) { console.log("parent", parent, JSON.stringify(this._config, null, 2)); throw new Error(`Parent path "${parent}" does not exist`); } } return has(this._config, path); } async remove(path: string): Promise<[Partial>, Static]> { this.throwIfRestricted(path); if (!this.has(path)) { throw new Error(`Path "${path}" does not exist`); } const current = cloneDeep(this._config); const removed = get(current, path) as Partial>; const config = omit(current, path); const newConfig = await this.set(config); return [removed, newConfig]; } }