diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts new file mode 100644 index 0000000..03641b3 --- /dev/null +++ b/app/__test__/app/App.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { ModuleBuildContext } from "../../src"; +import { type App, createApp } from "../../src/App"; +import * as proto from "../../src/data/prototype"; + +describe("App", () => { + test("seed includes ctx and app", async () => { + const called = mock(() => null); + await createApp({ + options: { + seed: async ({ app, ...ctx }) => { + called(); + expect(app).toBeDefined(); + expect(ctx).toBeDefined(); + expect(Object.keys(ctx)).toEqual([ + "connection", + "server", + "em", + "emgr", + "guard", + "flags", + "logger", + ]); + }, + }, + }).build(); + expect(called).toHaveBeenCalled(); + + const app = createApp({ + initialConfig: { + data: proto + .em({ + todos: proto.entity("todos", { + title: proto.text(), + }), + }) + .toJSON(), + }, + options: { + //manager: { verbosity: 2 }, + seed: async ({ app, ...ctx }: ModuleBuildContext & { app: App }) => { + await ctx.em.mutator("todos").insertOne({ title: "ctx" }); + await app.getApi().data.createOne("todos", { title: "api" }); + }, + }, + }); + await app.build(); + + const todos = await app.getApi().data.readMany("todos"); + expect(todos.length).toBe(2); + expect(todos[0].title).toBe("ctx"); + expect(todos[1].title).toBe("api"); + }); +}); diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts new file mode 100644 index 0000000..de004ac --- /dev/null +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { type InitialModuleConfigs, createApp } from "../../../src"; + +import type { Kysely } from "kysely"; +import { getDummyConnection } from "../../helper"; +import v7 from "./samples/v7.json"; + +// app expects migratable config to be present in database +async function createVersionedApp(config: InitialModuleConfigs) { + const { dummyConnection } = getDummyConnection(); + + if (!("version" in config)) throw new Error("config must have a version"); + const { version, ...rest } = config; + + const app = createApp({ connection: dummyConnection }); + await app.build(); + + const qb = app.modules.ctx().connection.kysely as Kysely; + const current = await qb + .selectFrom("__bknd") + .selectAll() + .where("type", "=", "config") + .executeTakeFirst(); + + await qb + .updateTable("__bknd") + .set("json", JSON.stringify(rest)) + .set("version", 7) + .where("id", "=", current!.id) + .execute(); + + const app2 = createApp({ + connection: dummyConnection, + }); + await app2.build(); + return app2; +} + +describe("Migrations", () => { + /** + * updated auth strategies to have "enabled" prop + * by default, migration should make all available strategies enabled + */ + test("migration from 7 to 8", async () => { + expect(v7.version).toBe(7); + + const app = await createVersionedApp(v7); + + expect(app.version()).toBe(8); + expect(app.toJSON(true).auth.strategies.password.enabled).toBe(true); + + const req = await app.server.request("/api/auth/password/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "test@test.com", + password: "12345678", + }), + }); + expect(req.ok).toBe(true); + const res = await req.json(); + expect(res.user.email).toBe("test@test.com"); + }); +}); diff --git a/app/__test__/modules/migrations/samples/v7.json b/app/__test__/modules/migrations/samples/v7.json new file mode 100644 index 0000000..4ef57cf --- /dev/null +++ b/app/__test__/modules/migrations/samples/v7.json @@ -0,0 +1,638 @@ +{ + "version": 7, + "server": { + "admin": { + "basepath": "", + "logo_return_path": "/", + "color_scheme": "light" + }, + "cors": { + "origin": "*", + "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ] + } + }, + "data": { + "basepath": "/api/data", + "entities": { + "products": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "brand": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "currency": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "price": { + "type": "number", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "price_compare": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "url": { + "type": "text", + "config": { + "html_config": { + "element": "input" + }, + "required": true, + "fillable": true, + "hidden": ["table"] + } + }, + "created_at": { + "type": "date", + "config": { + "type": "date", + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "description": { + "type": "text", + "config": { + "html_config": { + "element": "textarea", + "props": { + "rows": 4 + } + }, + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "images": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "products" + } + }, + "identifier": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "metadata": { + "type": "jsonschema", + "config": { + "schema": { + "type": "object", + "properties": { + "size": { + "type": "string" + } + } + }, + "required": false, + "fillable": true, + "hidden": ["table"] + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "media": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "path": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "folder": { + "type": "boolean", + "config": { + "default_value": false, + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "mime_type": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "size": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "scope": { + "type": "text", + "config": { + "hidden": true, + "fillable": ["create"], + "required": false + } + }, + "etag": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "modified_at": { + "type": "date", + "config": { + "type": "datetime", + "required": false, + "fillable": true, + "hidden": false + } + }, + "reference": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "entity_id": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "metadata": { + "type": "json", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "users": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "email": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "strategy": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["password"] + }, + "required": true, + "fillable": ["create"], + "hidden": ["update", "form"] + } + }, + "strategy_value": { + "type": "text", + "config": { + "fillable": ["create"], + "hidden": ["read", "table", "update", "form"], + "required": true + } + }, + "role": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": ["guest", "admin"] + }, + "required": false, + "fillable": true, + "hidden": false + } + }, + "username": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "name": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "product_likes": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "created_at": { + "type": "date", + "config": { + "type": "date", + "required": false, + "fillable": true, + "hidden": false + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "User", + "required": true, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "products_id": { + "type": "relation", + "config": { + "label": "Product", + "required": true, + "reference": "products", + "target": "products", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + } + }, + "config": { + "name": "Product Likes", + "sort_field": "id", + "sort_dir": "asc" + } + }, + "boards": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "private": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": true, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "images": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "boards", + "max_items": 5 + } + }, + "cover": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "boards_products": { + "type": "generated", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "boards_id": { + "type": "relation", + "config": { + "required": false, + "reference": "boards", + "target": "boards", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "products_id": { + "type": "relation", + "config": { + "required": false, + "reference": "products", + "target": "products", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": { + "poly_products_media_images": { + "type": "poly", + "source": "products", + "target": "media", + "config": { + "mappedBy": "images" + } + }, + "n1_product_likes_users": { + "type": "n:1", + "source": "product_likes", + "target": "users", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": true, + "with_limit": 5 + } + }, + "n1_product_likes_products": { + "type": "n:1", + "source": "product_likes", + "target": "products", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": true, + "with_limit": 5 + } + }, + "n1_boards_users": { + "type": "n:1", + "source": "boards", + "target": "users", + "config": { + "mappedBy": "", + "inversedBy": "", + "required": true, + "with_limit": 5 + } + }, + "poly_boards_media_images": { + "type": "poly", + "source": "boards", + "target": "media", + "config": { + "mappedBy": "images", + "targetCardinality": 5 + } + }, + "mn_boards_products_boards_products,boards_products": { + "type": "m:n", + "source": "boards", + "target": "products", + "config": {} + } + }, + "indices": { + "idx_unique_media_path": { + "entity": "media", + "fields": ["path"], + "unique": true + }, + "idx_media_reference": { + "entity": "media", + "fields": ["reference"], + "unique": false + }, + "idx_unique_users_email": { + "entity": "users", + "fields": ["email"], + "unique": true + }, + "idx_users_strategy": { + "entity": "users", + "fields": ["strategy"], + "unique": false + }, + "idx_users_strategy_value": { + "entity": "users", + "fields": ["strategy_value"], + "unique": false + }, + "idx_product_likes_unique_products_id_users_id": { + "entity": "product_likes", + "fields": ["products_id", "users_id"], + "unique": true + }, + "idx_media_entity_id": { + "entity": "media", + "fields": ["entity_id"], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "...", + "alg": "HS256", + "fields": ["id", "email", "role"] + }, + "cookie": { + "path": "/", + "sameSite": "lax", + "secure": true, + "httpOnly": true, + "expires": 604800, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "type": "password", + "config": { + "hashing": "sha256" + } + } + }, + "roles": { + "guest": { + "is_default": true, + "permissions": ["system.access.api", "data.entity.read"] + }, + "admin": { + "implicit_allow": true + } + }, + "guard": { + "enabled": true + } + }, + "media": { + "enabled": true, + "basepath": "/api/media", + "entity_name": "media", + "storage": {}, + "adapter": { + "type": "s3", + "config": { + "access_key": "...", + "secret_access_key": "...", + "url": "https://some.r2.cloudflarestorage.com/some" + } + } + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} diff --git a/app/src/App.ts b/app/src/App.ts index 1cf88ce..a6d1bc9 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -33,7 +33,7 @@ export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } export type AppOptions = { plugins?: AppPlugin[]; - seed?: (ctx: ModuleBuildContext) => Promise; + seed?: (ctx: ModuleBuildContext & { app: App }) => Promise; manager?: Omit; }; export type CreateAppConfig = { @@ -67,7 +67,6 @@ export class App { this.modules = new ModuleManager(connection, { ...(options?.manager ?? {}), initial: _initialConfig, - seed: options?.seed, onUpdated: async (key, config) => { // if the EventManager was disabled, we assume we shouldn't // respond to events, such as "onUpdated". @@ -115,15 +114,18 @@ export class App { await Promise.all(this.plugins.map((plugin) => plugin(this))); } + $console.log("App built"); await this.emgr.emit(new AppBuiltEvent({ app: this })); // first boot is set from ModuleManager when there wasn't a config table if (this.trigger_first_boot) { this.trigger_first_boot = false; await this.emgr.emit(new AppFirstBoot({ app: this })); + await this.options?.seed?.({ + ...this.modules.ctx(), + app: this, + }); } - - $console.log("App built"); } mutateConfig(module: Module) { diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 31a082e..9739566 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -7,7 +7,7 @@ import { type Strategy, } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { type DB, Exception, type PrimaryFieldType } from "core"; +import { $console, type DB, Exception, type PrimaryFieldType } from "core"; import { type Static, secureRandomString, transformObject } from "core/utils"; import type { Entity, EntityManager } from "data"; import { type FieldSchema, em, entity, enumm, text } from "data/prototype"; @@ -41,6 +41,12 @@ export class AppAuth extends Module { } } + // @todo: password strategy is required atm + if (!to.strategies?.password?.enabled) { + $console.warn("Password strategy cannot be disabled."); + to.strategies!.password!.enabled = true; + } + return to; } @@ -89,6 +95,9 @@ export class AppAuth extends Module { isStrategyEnabled(strategy: Strategy | string) { const name = typeof strategy === "string" ? strategy : strategy.getName(); + // for now, password is always active + if (name === "password") return true; + return this.config.strategies?.[name]?.enabled ?? false; } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index bf51eb8..232d463 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -252,14 +252,12 @@ export class DataController extends Controller { tb("param", Type.Object({ entity: Type.String() })), tb("query", querySchema), async (c) => { - //console.log("request", c.req.raw); const { entity } = c.req.param(); if (!this.entityExists(entity)) { console.warn("not found:", entity, definedEntities); return this.notFound(c); } const options = c.req.valid("query") as RepoQuery; - //console.log("before", this.ctx.emgr.Events); const result = await this.em.repository(entity).findMany(options); return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 6a4ac9d..3bec082 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,5 +1,5 @@ import { Guard } from "auth"; -import { BkndError, DebugLogger, withDisabledConsole } from "core"; +import { $console, BkndError, DebugLogger, withDisabledConsole } from "core"; import { EventManager } from "core/events"; import { clone, diff } from "core/object/diff"; import { @@ -153,7 +153,6 @@ export class ModuleManager { this.modules = {} as Modules; this.emgr = new EventManager(); this.logger = new DebugLogger(this.verbosity === Verbosity.log); - const context = this.ctx(true); let initial = {} as Partial; if (options?.initial) { @@ -169,15 +168,29 @@ export class ModuleManager { } } - for (const key in MODULES) { - const moduleConfig = key in initial ? initial[key] : {}; - const module = new MODULES[key](moduleConfig, context) as Module; - module.setListener(async (c) => { - await this.onModuleConfigUpdated(key, c); - }); + this.createModules(initial); + } - this.modules[key] = module; + private createModules(initial: Partial) { + this.logger.context("createModules").log("creating modules"); + try { + const context = this.ctx(true); + + for (const key in MODULES) { + const moduleConfig = key in initial ? initial[key] : {}; + const module = new MODULES[key](moduleConfig, context) as Module; + module.setListener(async (c) => { + await this.onModuleConfigUpdated(key, c); + }); + + this.modules[key] = module; + } + this.logger.log("modules created"); + } catch (e) { + this.logger.log("failed to create modules", e); + throw e; } + this.logger.clear(); } private get verbosity() { @@ -197,7 +210,7 @@ export class ModuleManager { if (this.options?.onUpdated) { await this.options.onUpdated(key as any, config); } else { - this.buildModules(); + await this.buildModules(); } } @@ -368,15 +381,27 @@ export class ModuleManager { } private async migrate() { + const state = { + success: false, + migrated: false, + version: { + before: this.version(), + after: this.version(), + }, + }; this.logger.context("migrate").log("migrating?", this.version(), CURRENT_VERSION); if (this.version() < CURRENT_VERSION) { + state.version.before = this.version(); + this.logger.log("there are migrations, verify version"); // sync __bknd table await this.syncConfigTable(); // modules must be built before migration + this.logger.log("building modules"); await this.buildModules({ graceful: true }); + this.logger.log("modules built"); try { const state = await this.fetch(); @@ -405,17 +430,27 @@ export class ModuleManager { version = _version; configs = _configs; - this.setConfigs(configs); - this._version = version; + state.version.after = version; + state.migrated = true; + this.ctx().flags.sync_required = true; + + this.logger.log("setting configs"); + this.createModules(configs); + await this.buildModules(); + this.logger.log("migrated to", version); + $console.log("Migrated config from", state.version.before, "to", state.version.after); await this.save(); } else { this.logger.log("no migrations needed"); } + state.success = true; this.logger.clear(); + + return state; } private setConfigs(configs: ModuleConfigs): void { @@ -480,10 +515,16 @@ export class ModuleManager { } // migrate to latest if needed - await this.migrate(); + this.logger.log("check migrate"); + const migration = await this.migrate(); + if (migration.success && migration.migrated) { + this.logger.log("skipping build after migration"); + } else { + this.logger.log("trigger build modules"); + await this.buildModules(); + } - this.logger.log("building"); - await this.buildModules(); + this.logger.log("done"); return this; } @@ -496,7 +537,7 @@ export class ModuleManager { reloaded: false, }; - this.logger.log("buildModules() triggered", options, this._built); + this.logger.context("buildModules").log("triggered", options, this._built); if (options?.graceful && this._built) { this.logger.log("skipping build (graceful)"); return state; @@ -536,8 +577,10 @@ export class ModuleManager { } // reset all falgs + this.logger.log("resetting flags"); ctx.flags = Module.ctx_flags; + this.logger.clear(); return state; } diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index b955476..8297a45 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -1,4 +1,4 @@ -import { _jsonp } from "core/utils"; +import { _jsonp, transformObject } from "core/utils"; import { type Kysely, sql } from "kysely"; import { set } from "lodash-es"; @@ -72,13 +72,25 @@ export const migrations: Migration[] = [ }; }, }, - /*{ + { version: 8, - up: async (config, { db }) => { - await db.deleteFrom(TABLE_NAME).where("type", "=", "diff").execute(); - return config; - } - }*/ + up: async (config) => { + const strategies = transformObject(config.auth.strategies, (strategy) => { + return { + ...strategy, + enabled: true, + }; + }); + + return { + ...config, + auth: { + ...config.auth, + strategies: strategies, + }, + }; + }, + }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index e8868dc..8357fc8 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -238,7 +238,6 @@ export function FormContextOverride({ ...overrides, ...additional, }; - console.log("context", context); return {children}; } diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 61d3339..2120dfd 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -51,9 +51,9 @@ export default { if (firstStart) { console.log("[DB]", credentials); firstStart = false; - console.log("\n\n[APP ROUTES]"); + console.log("\n[APP ROUTES]"); showRoutes(app.server); - console.log("-------\n\n"); + console.log("-------\n"); } }