import { get, has, omit, set } from "lodash-es"; import { getFullPathKeys, mergeObjectWith } from "../utils"; import { type s, parse, stripMark } from "bknd/core"; export type SchemaObjectOptions = { onUpdate?: (config: s.Static) => void | Promise; onBeforeUpdate?: ( from: s.Static, to: s.Static, ) => s.Static | Promise>; restrictPaths?: string[]; overwritePaths?: (RegExp | string)[]; forceParse?: boolean; }; type TSchema = s.ObjectSchema; export class SchemaObject { private readonly _default: Partial>; private _value: s.Static; private _config: s.Static; private _restriction_bypass: boolean = false; constructor( private _schema: Schema, initial?: Partial>, private options?: SchemaObjectOptions, ) { this._default = _schema.template({}, { withOptional: true }) as any; this._value = parse(_schema, structuredClone(initial ?? {}), { withDefaults: true, withExtendedDefaults: true, forceParse: this.isForceParse(), skipMark: this.isForceParse(), }); this._config = Object.freeze(this._value); } protected isForceParse(): boolean { return this.options?.forceParse ?? true; } default() { return this._default; } private async onBeforeUpdate( from: s.Static, to: s.Static, ): Promise> { if (this.options?.onBeforeUpdate) { return this.options.onBeforeUpdate(from, to); } return to; } get(options?: { stripMark?: boolean }): s.Static { if (options?.stripMark) { return stripMark(this._config); } return this._config; } clone() { return structuredClone(this._config); } async set(config: s.Static, noEmit?: boolean): Promise> { const valid = parse(this._schema, structuredClone(config) as any, { coerce: false, 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>, s.Static]> { const current = this.clone(); const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = mergeObjectWith(current, partial, (objValue, srcValue) => { if (Array.isArray(objValue) && Array.isArray(srcValue)) { return srcValue; } }); 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); } }); }); 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) => { return k2 !== k && k2.startsWith(k); }), ) : overwritePaths; for (const p of specific) { set(config, p, get(partial, p)); } } } const newConfig = await this.set(config); return [partial, newConfig]; } async overwrite( path: string, value: any, ): Promise<[Partial>, s.Static]> { const current = this.clone(); const partial = path.length > 0 ? (set({}, path, value) as Partial>) : value; this.throwIfRestricted(partial); // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = set(current, path, value); 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)) { throw new Error(`Parent path "${parent}" does not exist`); } } return has(this._config, path); } async remove(path: string): Promise<[Partial>, s.Static]> { this.throwIfRestricted(path); if (!this.has(path)) { throw new Error(`Path "${path}" does not exist`); } const current = this.clone(); const removed = get(current, path) as Partial>; const config = omit(current, path); const newConfig = await this.set(config as any); return [removed, newConfig]; } }