Merge pull request #54 from bknd-io/fix/nextjs-schema-update

fixed schema updates to fail in nextjs envs due to lodash's merge
This commit is contained in:
dswbx
2025-01-25 09:17:23 +01:00
committed by GitHub
11 changed files with 125 additions and 31 deletions

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { Guard } from "../../src/auth"; import { Guard } from "../../src/auth";
import { DebugLogger } from "../../src/core";
import { EventManager } from "../../src/core/events"; import { EventManager } from "../../src/core/events";
import { Default, stripMark } from "../../src/core/utils"; import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data"; import { EntityManager } from "../../src/data";
@@ -17,6 +18,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
emgr: new EventManager(), emgr: new EventManager(),
guard: new Guard(), guard: new Guard(),
flags: Module.ctx_flags, flags: Module.ctx_flags,
logger: new DebugLogger(false),
...overrides ...overrides
}; };
} }

View File

@@ -1,10 +1,10 @@
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es"; import { get, has, omit, set } from "lodash-es";
import { import {
Default, Default,
type Static, type Static,
type TObject, type TObject,
getFullPathKeys, getFullPathKeys,
mark, mergeObjectWith,
parse, parse,
stripMark stripMark
} from "../utils"; } from "../utils";
@@ -33,7 +33,7 @@ export class SchemaObject<Schema extends TObject> {
) { ) {
this._default = Default(_schema, {} as any) as any; this._default = Default(_schema, {} as any) as any;
this._value = initial this._value = initial
? parse(_schema, cloneDeep(initial as any), { ? parse(_schema, structuredClone(initial as any), {
forceParse: this.isForceParse(), forceParse: this.isForceParse(),
skipMark: this.isForceParse() skipMark: this.isForceParse()
}) })
@@ -64,8 +64,12 @@ export class SchemaObject<Schema extends TObject> {
return this._config; return this._config;
} }
clone() {
return structuredClone(this._config);
}
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> { async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
const valid = parse(this._schema, cloneDeep(config) as any, { const valid = parse(this._schema, structuredClone(config) as any, {
forceParse: true, forceParse: true,
skipMark: this.isForceParse() skipMark: this.isForceParse()
}); });
@@ -114,7 +118,7 @@ export class SchemaObject<Schema extends TObject> {
} }
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> { async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config); const current = this.clone();
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value; const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial); this.throwIfRestricted(partial);
@@ -122,11 +126,13 @@ export class SchemaObject<Schema extends TObject> {
// overwrite arrays and primitives, only deep merge objects // overwrite arrays and primitives, only deep merge objects
// @ts-ignore // @ts-ignore
const config = mergeWith(current, partial, (objValue, srcValue) => { //console.log("---alt:new", _jsonp(mergeObject(current, partial)));
const config = mergeObjectWith(current, partial, (objValue, srcValue) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) { if (Array.isArray(objValue) && Array.isArray(srcValue)) {
return srcValue; return srcValue;
} }
}); });
//console.log("---new", _jsonp(config));
//console.log("overwritePaths", this.options?.overwritePaths); //console.log("overwritePaths", this.options?.overwritePaths);
if (this.options?.overwritePaths) { if (this.options?.overwritePaths) {
@@ -164,14 +170,14 @@ export class SchemaObject<Schema extends TObject> {
} }
} }
//console.log("patch", { path, value, partial, config, current }); //console.log("patch", _jsonp({ path, value, partial, config, current }));
const newConfig = await this.set(config); const newConfig = await this.set(config);
return [partial, newConfig]; return [partial, newConfig];
} }
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> { async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
const current = cloneDeep(this._config); const current = this.clone();
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value; const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
this.throwIfRestricted(partial); this.throwIfRestricted(partial);
@@ -192,7 +198,7 @@ export class SchemaObject<Schema extends TObject> {
if (p.length > 1) { if (p.length > 1) {
const parent = p.slice(0, -1).join("."); const parent = p.slice(0, -1).join(".");
if (!has(this._config, parent)) { if (!has(this._config, parent)) {
console.log("parent", parent, JSON.stringify(this._config, null, 2)); //console.log("parent", parent, JSON.stringify(this._config, null, 2));
throw new Error(`Parent path "${parent}" does not exist`); throw new Error(`Parent path "${parent}" does not exist`);
} }
} }
@@ -207,7 +213,7 @@ export class SchemaObject<Schema extends TObject> {
throw new Error(`Path "${path}" does not exist`); throw new Error(`Path "${path}" does not exist`);
} }
const current = cloneDeep(this._config); const current = this.clone();
const removed = get(current, path) as Partial<Static<Schema>>; const removed = get(current, path) as Partial<Static<Schema>>;
const config = omit(current, path); const config = omit(current, path);
const newConfig = await this.set(config); const newConfig = await this.set(config);

View File

@@ -4,6 +4,14 @@ export function _jsonp(obj: any, space = 2): string {
return JSON.stringify(obj, null, space); return JSON.stringify(obj, null, space);
} }
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === "[object Object]";
}
export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T { export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
try { try {
@@ -97,15 +105,6 @@ export function objectEach<T extends Record<string, any>, U>(
); );
} }
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item) {
return item && typeof item === "object" && !Array.isArray(item);
}
/** /**
* Deep merge two objects. * Deep merge two objects.
* @param target * @param target
@@ -197,3 +196,73 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
return acc; return acc;
}, {} as any); }, {} as any);
} }
/**
* Lodash's merge implementation caused issues in Next.js environments
* From: https://thescottyjam.github.io/snap.js/#!/nolodash/merge
* NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
* @param object
* @param sources
*/
export function mergeObject(object, ...sources) {
for (const source of sources) {
for (const [key, value] of Object.entries(source)) {
if (value === undefined) {
continue;
}
// These checks are a week attempt at mimicking the various edge-case behaviors
// that Lodash's `_.merge()` exhibits. Feel free to simplify and
// remove checks that you don't need.
if (!isPlainObject(value) && !Array.isArray(value)) {
object[key] = value;
} else if (Array.isArray(value) && !Array.isArray(object[key])) {
object[key] = value;
} else if (!isObject(object[key])) {
object[key] = value;
} else {
mergeObject(object[key], value);
}
}
}
return object;
}
/**
* Lodash's mergeWith implementation caused issues in Next.js environments
* From: https://thescottyjam.github.io/snap.js/#!/nolodash/mergeWith
* NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
* @param object
* @param sources
* @param customizer
*/
export function mergeObjectWith(object, source, customizer) {
for (const [key, value] of Object.entries(source)) {
const mergedValue = customizer(object[key], value, key, object, source);
if (mergedValue !== undefined) {
object[key] = mergedValue;
continue;
}
// Otherwise, fall back to default behavior
if (value === undefined) {
continue;
}
// These checks are a week attempt at mimicking the various edge-case behaviors
// that Lodash's `_.merge()` exhibits. Feel free to simplify and
// remove checks that you don't need.
if (!isPlainObject(value) && !Array.isArray(value)) {
object[key] = value;
} else if (Array.isArray(value) && !Array.isArray(object[key])) {
object[key] = value;
} else if (!isObject(object[key])) {
object[key] = value;
} else {
mergeObjectWith(object[key], value, customizer);
}
}
return object;
}

View File

@@ -13,7 +13,15 @@ import { type AppDataConfig, dataConfigSchema } from "./data-schema";
export class AppData extends Module<typeof dataConfigSchema> { export class AppData extends Module<typeof dataConfigSchema> {
override async build() { override async build() {
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => { const {
entities: _entities = {},
relations: _relations = {},
indices: _indices = {}
} = this.config;
this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities));
const entities = transformObject(_entities, (entityConfig, name) => {
return constructEntity(name, entityConfig); return constructEntity(name, entityConfig);
}); });
@@ -21,14 +29,14 @@ export class AppData extends Module<typeof dataConfigSchema> {
const name = typeof _e === "string" ? _e : _e.name; const name = typeof _e === "string" ? _e : _e.name;
const entity = entities[name]; const entity = entities[name];
if (entity) return entity; if (entity) return entity;
throw new Error(`Entity "${name}" not found`); throw new Error(`[AppData] Entity "${name}" not found`);
}; };
const relations = transformObject(this.config.relations ?? {}, (relation) => const relations = transformObject(_relations, (relation) =>
constructRelation(relation, _entity) constructRelation(relation, _entity)
); );
const indices = transformObject(this.config.indices ?? {}, (index, name) => { const indices = transformObject(_indices, (index, name) => {
const entity = _entity(index.entity)!; const entity = _entity(index.entity)!;
const fields = index.fields.map((f) => entity.field(f)!); const fields = index.fields.map((f) => entity.field(f)!);
return new EntityIndex(entity, fields, index.unique, name); return new EntityIndex(entity, fields, index.unique, name);
@@ -52,6 +60,7 @@ export class AppData extends Module<typeof dataConfigSchema> {
); );
this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.ctx.logger.clear();
this.setBuilt(); this.setBuilt();
} }

View File

@@ -1,6 +1,6 @@
import type { App } from "App"; import type { App } from "App";
import type { Guard } from "auth"; import type { Guard } from "auth";
import { SchemaObject } from "core"; import { type DebugLogger, SchemaObject } from "core";
import type { EventManager } from "core/events"; import type { EventManager } from "core/events";
import type { Static, TSchema } from "core/utils"; import type { Static, TSchema } from "core/utils";
import { import {
@@ -35,6 +35,7 @@ export type ModuleBuildContext = {
em: EntityManager; em: EntityManager;
emgr: EventManager<any>; emgr: EventManager<any>;
guard: Guard; guard: Guard;
logger: DebugLogger;
flags: (typeof Module)["ctx_flags"]; flags: (typeof Module)["ctx_flags"];
}; };

View File

@@ -231,7 +231,8 @@ export class ModuleManager {
em: this.em, em: this.em,
emgr: this.emgr, emgr: this.emgr,
guard: this.guard, guard: this.guard,
flags: Module.ctx_flags flags: Module.ctx_flags,
logger: this.logger
}; };
} }

View File

@@ -43,7 +43,11 @@ export function useBkndData() {
return { return {
config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => { config: async (partial: Partial<TAppDataEntity["config"]>): Promise<boolean> => {
console.log("patch config", entityName, partial); console.log("patch config", entityName, partial);
return await bkndActions.patch("data", `entities.${entityName}.config`, partial); return await bkndActions.overwrite(
"data",
`entities.${entityName}.config`,
partial
);
}, },
fields: entityFieldActions(bkndActions, entityName) fields: entityFieldActions(bkndActions, entityName)
}; };

View File

@@ -16,6 +16,7 @@ export type JsonSchemaFormProps = any & {
uiSchema?: any; uiSchema?: any;
direction?: "horizontal" | "vertical"; direction?: "horizontal" | "vertical";
onChange?: (value: any, isValid: () => boolean) => void; onChange?: (value: any, isValid: () => boolean) => void;
cleanOnChange?: boolean;
}; };
export type JsonSchemaFormRef = { export type JsonSchemaFormRef = {
@@ -36,6 +37,7 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
templates, templates,
fields, fields,
widgets, widgets,
cleanOnChange,
...props ...props
}, },
ref ref
@@ -51,8 +53,8 @@ export const JsonSchemaForm = forwardRef<JsonSchemaFormRef, JsonSchemaFormProps>
return false; return false;
}; };
const handleChange = ({ formData }: any, e) => { const handleChange = ({ formData }: any, e) => {
const clean = JSON.parse(JSON.stringify(formData)); const clean = cleanOnChange !== false ? JSON.parse(JSON.stringify(formData)) : formData;
//console.log("Data changed: ", clean, JSON.stringify(formData, null, 2)); console.log("Data changed: ", clean, { cleanOnChange });
setValue(clean); setValue(clean);
onChange?.(clean, () => isValid(clean)); onChange?.(clean, () => isValid(clean));
}; };

View File

@@ -201,7 +201,7 @@ const EntityContextMenu = ({
separator, separator,
{ {
icon: IconSettings, icon: IconSettings,
label: "Settings", label: "Advanced",
onClick: () => onClick: () =>
navigate(routes.settings.path(["data", "entities", entity.name]), { navigate(routes.settings.path(["data", "entities", entity.name]), {
absolute: true absolute: true

View File

@@ -40,8 +40,8 @@ export function DataEntityList({ params }) {
useBrowserTitle(["Data", entity?.label ?? params.entity]); useBrowserTitle(["Data", entity?.label ?? params.entity]);
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const search = useSearch(searchSchema, { const search = useSearch(searchSchema, {
select: undefined, select: entity.getSelect(undefined, "form"),
sort: undefined sort: entity.getDefaultSort()
}); });
const $q = useApiQuery( const $q = useApiQuery(

BIN
bun.lockb

Binary file not shown.