strengthened schema ensuring for system entities

This commit is contained in:
dswbx
2025-01-18 12:39:34 +01:00
parent 145b47e942
commit db10188945
12 changed files with 118 additions and 36 deletions

View File

@@ -1,7 +1,7 @@
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "../../src"; import { createApp } from "../../src";
import { AuthController } from "../../src/auth/api/AuthController"; import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, text } from "../../src/data"; import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
@@ -125,6 +125,40 @@ describe("AppAuth", () => {
const fields = e.fields.map((f) => f.name); const fields = e.fields.map((f) => f.name);
expect(e.type).toBe("system"); expect(e.type).toBe("system");
expect(fields).toContain("additional"); expect(fields).toContain("additional");
expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]); expect(fields).toEqual(["id", "additional", "email", "strategy", "strategy_value", "role"]);
});
test("ensure user field configs is always correct", async () => {
const app = createApp({
initialConfig: {
auth: {
enabled: true
},
data: em({
users: entity("users", {
strategy: text({
fillable: true,
hidden: false
}),
strategy_value: text({
fillable: true,
hidden: false
})
})
}).toJSON()
}
});
await app.build();
const users = app.em.entity("users");
const props = ["hidden", "fillable", "required"];
for (const [name, _authFieldProto] of Object.entries(AppAuth.usersFields)) {
const authField = make(name, _authFieldProto as any);
const field = users.field(name)!;
for (const prop of props) {
expect(field.config[prop]).toBe(authField.config[prop]);
}
}
}); });
}); });

View File

@@ -39,6 +39,7 @@ describe("AppMedia", () => {
expect(fields).toContain("additional"); expect(fields).toContain("additional");
expect(fields).toEqual([ expect(fields).toEqual([
"id", "id",
"additional",
"path", "path",
"folder", "folder",
"mime_type", "mime_type",
@@ -48,8 +49,7 @@ describe("AppMedia", () => {
"modified_at", "modified_at",
"reference", "reference",
"entity_id", "entity_id",
"metadata", "metadata"
"additional"
]); ]);
}); });
}); });

View File

@@ -157,8 +157,7 @@ describe("Module", async () => {
entities: [ entities: [
{ {
name: "u", name: "u",
// ensured properties must come first fields: ["id", "name", "important"],
fields: ["id", "important", "name"],
// ensured type must be present // ensured type must be present
type: "system" type: "system"
}, },

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.6.0-rc.11", "version": "0.6.0-rc.12",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {

View File

@@ -118,6 +118,8 @@ export class App {
this.trigger_first_boot = false; this.trigger_first_boot = false;
await this.emgr.emit(new AppFirstBoot({ app: this })); await this.emgr.emit(new AppFirstBoot({ app: this }));
} }
console.log("[APP] built");
} }
mutateConfig<Module extends keyof Modules>(module: Module) { mutateConfig<Module extends keyof Modules>(module: Module) {

View File

@@ -10,7 +10,7 @@ import type { PasswordStrategy } from "auth/authenticate/strategies";
import { type DB, Exception, type PrimaryFieldType } from "core"; import { type DB, Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Static, secureRandomString, transformObject } from "core/utils";
import type { Entity, EntityManager } from "data"; import type { Entity, EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype"; import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
@@ -224,7 +224,8 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} }
private toggleStrategyValueVisibility(visible: boolean) { private toggleStrategyValueVisibility(visible: boolean) {
const field = this.getUsersEntity().field("strategy_value")!; const toggle = (name: string, visible: boolean) => {
const field = this.getUsersEntity().field(name)!;
if (visible) { if (visible) {
field.config.hidden = false; field.config.hidden = false;
@@ -235,6 +236,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
field.config.hidden = template.hidden; field.config.hidden = template.hidden;
field.config.fillable = template.fillable; field.config.fillable = template.fillable;
} }
};
toggle("strategy_value", visible);
toggle("strategy", visible);
// @todo: think about a PasswordField that automatically hashes on save? // @todo: think about a PasswordField that automatically hashes on save?
} }
@@ -250,7 +255,10 @@ export class AppAuth extends Module<typeof authConfigSchema> {
static usersFields = { static usersFields = {
email: text().required(), email: text().required(),
strategy: text({ fillable: ["create"], hidden: ["form"] }).required(), strategy: text({
fillable: ["create"],
hidden: ["update", "form"]
}).required(),
strategy_value: text({ strategy_value: text({
fillable: ["create"], fillable: ["create"],
hidden: ["read", "table", "update", "form"] hidden: ["read", "table", "update", "form"]

View File

@@ -111,13 +111,13 @@ export class EntityManager<TBD extends object = DefaultDB> {
// caused issues because this.entity() was using a reference (for when initial config was given) // caused issues because this.entity() was using a reference (for when initial config was given)
} }
entity(e: Entity | keyof TBD | string): Entity { entity(e: Entity | keyof TBD | string, silent?: boolean): Entity {
// make sure to always retrieve by name // make sure to always retrieve by name
const entity = this.entities.find((entity) => const entity = this.entities.find((entity) =>
e instanceof Entity ? entity.name === e.name : entity.name === e e instanceof Entity ? entity.name === e.name : entity.name === e
); );
if (!entity) { if (!entity && !silent) {
// @ts-ignore // @ts-ignore
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e); throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
} }

View File

@@ -12,6 +12,9 @@ import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import type { EntityManager } from "../entities"; import type { EntityManager } from "../entities";
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors"; import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
// @todo: contexts need to be reworked
// e.g. "table" is irrelevant, because if read is not given, it fails
export const ActionContext = ["create", "read", "update", "delete"] as const; export const ActionContext = ["create", "read", "update", "delete"] as const;
export type TActionContext = (typeof ActionContext)[number]; export type TActionContext = (typeof ActionContext)[number];

View File

@@ -14,6 +14,7 @@ import {
} from "data"; } from "data";
import { Entity } from "data"; import { Entity } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { isEqual } from "lodash-es";
export type ServerEnv = { export type ServerEnv = {
Variables: { Variables: {
@@ -154,28 +155,33 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
} }
protected ensureEntity(entity: Entity) { protected ensureEntity(entity: Entity) {
const instance = this.ctx.em.entity(entity.name, true);
// check fields // check fields
if (!this.ctx.em.hasEntity(entity.name)) { if (!instance) {
this.ctx.em.addEntity(entity); this.ctx.em.addEntity(entity);
this.ctx.flags.sync_required = true; this.ctx.flags.sync_required = true;
return; return;
} }
const instance = this.ctx.em.entity(entity.name);
// if exists, check all fields required are there // if exists, check all fields required are there
// @todo: check if the field also equal // @todo: check if the field also equal
for (const field of instance.fields) { for (const field of entity.fields) {
const _field = entity.field(field.name); const instanceField = instance.field(field.name);
if (!_field) { if (!instanceField) {
entity.addField(field); instance.addField(field);
this.ctx.flags.sync_required = true; this.ctx.flags.sync_required = true;
} else {
const changes = this.setEntityFieldConfigs(field, instanceField);
if (changes > 0) {
this.ctx.flags.sync_required = true;
}
} }
} }
// replace entity (mainly to keep the ensured type) // replace entity (mainly to keep the ensured type)
this.ctx.em.__replaceEntity( this.ctx.em.__replaceEntity(
new Entity(entity.name, entity.fields, instance.config, entity.type) new Entity(instance.name, instance.fields, instance.config, entity.type)
); );
} }
@@ -193,6 +199,21 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
return schema; return schema;
} }
protected setEntityFieldConfigs(
parent: Field,
child: Field,
props: string[] = ["hidden", "fillable", "required"]
) {
let changes = 0;
for (const prop of props) {
if (!isEqual(child.config[prop], parent.config[prop])) {
child.config[prop] = parent.config[prop];
changes++;
}
}
return changes;
}
protected replaceEntityField( protected replaceEntityField(
_entity: string | Entity, _entity: string | Entity,
field: Field | string, field: Field | string,
@@ -202,6 +223,10 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
const name = typeof field === "string" ? field : field.name; const name = typeof field === "string" ? field : field.name;
const newField = const newField =
_newField instanceof FieldPrototype ? make(name, _newField as any) : _newField; _newField instanceof FieldPrototype ? make(name, _newField as any) : _newField;
// ensure keeping vital config
this.setEntityFieldConfigs(entity.field(name)!, newField);
entity.__replaceField(name, newField); entity.__replaceField(name, newField);
} }
} }

View File

@@ -88,6 +88,7 @@ export type ModuleManagerOptions = {
}; };
type ConfigTable<Json = ModuleConfigs> = { type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number; version: number;
type: "config" | "diff" | "backup"; type: "config" | "diff" | "backup";
json: Json; json: Json;
@@ -236,10 +237,10 @@ export class ModuleManager {
private async fetch(): Promise<ConfigTable> { private async fetch(): Promise<ConfigTable> {
this.logger.context("fetch").log("fetching"); this.logger.context("fetch").log("fetching");
const startTime = performance.now();
// disabling console log, because the table might not exist yet // disabling console log, because the table might not exist yet
return await withDisabledConsole(async () => { const result = await withDisabledConsole(async () => {
const startTime = performance.now();
const { data: result } = await this.repo().findOne( const { data: result } = await this.repo().findOne(
{ type: "config" }, { type: "config" },
{ {
@@ -251,9 +252,16 @@ export class ModuleManager {
throw BkndError.with("no config"); throw BkndError.with("no config");
} }
this.logger.log("took", performance.now() - startTime, "ms", result.version).clear(); return result as unknown as ConfigTable;
return result as ConfigTable;
}, ["log", "error", "warn"]); }, ["log", "error", "warn"]);
this.logger
.log("took", performance.now() - startTime, "ms", {
version: result.version,
id: result.id
})
.clear();
return result;
} }
async save() { async save() {
@@ -390,6 +398,7 @@ export class ModuleManager {
} }
private setConfigs(configs: ModuleConfigs): void { private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs");
objectEach(configs, (config, key) => { objectEach(configs, (config, key) => {
try { try {
// setting "noEmit" to true, to not force listeners to update // setting "noEmit" to true, to not force listeners to update

View File

@@ -44,6 +44,11 @@ export class SystemController extends Controller {
hono.use(permission(SystemPermissions.configRead)); hono.use(permission(SystemPermissions.configRead));
hono.get("/raw", permission([SystemPermissions.configReadSecrets]), async (c) => {
// @ts-expect-error "fetch" is private
return c.json(await this.app.modules.fetch());
});
hono.get( hono.get(
"/:module?", "/:module?",
tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })), tb("param", Type.Object({ module: Type.Optional(StringEnum(MODULE_NAMES)) })),

View File

@@ -54,9 +54,6 @@ body,
} }
} }
@layer utilities {
}
#bknd-admin, #bknd-admin,
.bknd-admin { .bknd-admin {
/* Chrome, Edge, and Safari */ /* Chrome, Edge, and Safari */