diff --git a/README.md b/README.md index 644f0d0..d5070c9 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Creating digital products always requires developing both the backend (the logic * 🏃‍♂️ Multiple run modes * standalone using the CLI * using a JavaScript runtime (Node, Bun, workerd) - * using a React framework (Astro, Remix, Next.js) + * using a React framework (Next.js, React Router, Astro) * 📦 Official API and React SDK with type-safety * ⚛️ React elements for auto-configured authentication and media components diff --git a/app/__test__/App.spec.ts b/app/__test__/App.spec.ts index 395aec0..79fdc51 100644 --- a/app/__test__/App.spec.ts +++ b/app/__test__/App.spec.ts @@ -1,9 +1,9 @@ -import { afterAll, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { App } from "../src"; import { getDummyConnection } from "./helper"; const { dummyConnection, afterAllCleanup } = getDummyConnection(); -afterAll(afterAllCleanup); +afterEach(afterAllCleanup); describe("App tests", async () => { test("boots and pongs", async () => { @@ -12,4 +12,16 @@ describe("App tests", async () => { //expect(await app.data?.em.ping()).toBeTrue(); }); + + /*test.only("what", async () => { + const app = new App(dummyConnection, { + auth: { + enabled: true, + }, + }); + await app.module.auth.build(); + await app.module.data.build(); + console.log(app.em.entities.map((e) => e.name)); + console.log(await app.em.schema().getDiff()); + });*/ }); diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index 785f1ab..a479d19 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -32,7 +32,7 @@ describe("MediaApi", () => { host, basepath, }); - expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`); + expect(api.getFileUploadUrl({ path: "path" } as any)).toBe(`${host}${basepath}/upload/path`); }); it("should have correct upload headers", () => { diff --git a/app/__test__/auth/strategies/OAuthStrategy.spec.ts b/app/__test__/auth/strategies/OAuthStrategy.spec.ts index eb1a397..93ceae0 100644 --- a/app/__test__/auth/strategies/OAuthStrategy.spec.ts +++ b/app/__test__/auth/strategies/OAuthStrategy.spec.ts @@ -7,8 +7,8 @@ describe("OAuthStrategy", async () => { const strategy = new OAuthStrategy({ type: "oidc", client: { - client_id: process.env.OAUTH_CLIENT_ID, - client_secret: process.env.OAUTH_CLIENT_SECRET, + client_id: process.env.OAUTH_CLIENT_ID!, + client_secret: process.env.OAUTH_CLIENT_SECRET!, }, name: "google", }); @@ -19,11 +19,6 @@ describe("OAuthStrategy", async () => { const config = await strategy.getConfig(); console.log("config", JSON.stringify(config, null, 2)); - const request = await strategy.request({ - redirect_uri, - state, - }); - const server = Bun.serve({ fetch: async (req) => { const url = new URL(req.url); @@ -39,6 +34,11 @@ describe("OAuthStrategy", async () => { return new Response("Bun!"); }, }); + + const request = await strategy.request({ + redirect_uri, + state, + }); console.log("request", request); await new Promise((resolve) => setTimeout(resolve, 100000)); diff --git a/app/__test__/core/env.spec.ts b/app/__test__/core/env.spec.ts index d4c5ba3..b3d13ab 100644 --- a/app/__test__/core/env.spec.ts +++ b/app/__test__/core/env.spec.ts @@ -14,6 +14,7 @@ describe("env", () => { expect(is_toggled(1)).toBe(true); expect(is_toggled(0)).toBe(false); expect(is_toggled("anything else")).toBe(false); + expect(is_toggled(undefined, true)).toBe(true); }); test("env()", () => { diff --git a/app/__test__/data/relations.test.ts b/app/__test__/data/relations.test.ts index b4ff708..07ecd19 100644 --- a/app/__test__/data/relations.test.ts +++ b/app/__test__/data/relations.test.ts @@ -27,7 +27,7 @@ describe("Relations", async () => { const sql1 = schema .createTable("posts") - .addColumn(...r1.schema()!) + .addColumn(...em.connection.getFieldSchema(r1.schema())!) .compile().sql; expect(sql1).toBe( @@ -43,7 +43,7 @@ describe("Relations", async () => { const sql2 = schema .createTable("posts") - .addColumn(...r2.schema()!) + .addColumn(...em.connection.getFieldSchema(r2.schema())!) .compile().sql; expect(sql2).toBe( diff --git a/app/__test__/data/specs/SchemaManager.spec.ts b/app/__test__/data/specs/SchemaManager.spec.ts index 9e9fe90..7c2322b 100644 --- a/app/__test__/data/specs/SchemaManager.spec.ts +++ b/app/__test__/data/specs/SchemaManager.spec.ts @@ -15,7 +15,7 @@ describe("SchemaManager tests", async () => { const em = new EntityManager([entity], dummyConnection, [], [index]); const schema = new SchemaManager(em); - const introspection = schema.getIntrospectionFromEntity(em.entities[0]); + const introspection = schema.getIntrospectionFromEntity(em.entities[0]!); expect(introspection).toEqual({ name: "test", isView: false, @@ -109,7 +109,7 @@ describe("SchemaManager tests", async () => { await schema.sync({ force: true, drop: true }); const diffAfter = await schema.getDiff(); - console.log("diffAfter", diffAfter); + //console.log("diffAfter", diffAfter); expect(diffAfter.length).toBe(0); await kysely.schema.dropTable(table).execute(); diff --git a/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts b/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts new file mode 100644 index 0000000..ee46b7b --- /dev/null +++ b/app/__test__/data/specs/connection/SqliteIntrospector.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { SqliteIntrospector } from "data/connection"; +import { getDummyDatabase } from "../../helper"; +import { Kysely, SqliteDialect } from "kysely"; + +function create() { + const database = getDummyDatabase().dummyDb; + return new Kysely({ + dialect: new SqliteDialect({ database }), + }); +} + +function createLibsql() { + const database = getDummyDatabase().dummyDb; + return new Kysely({ + dialect: new SqliteDialect({ database }), + }); +} + +describe("SqliteIntrospector", () => { + test("asdf", async () => { + const kysely = create(); + + await kysely.schema + .createTable("test") + .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull()) + .addColumn("string", "text", (col) => col.notNull()) + .addColumn("number", "integer") + .execute(); + + await kysely.schema + .createIndex("idx_test_string") + .on("test") + .columns(["string"]) + .unique() + .execute(); + + await kysely.schema + .createTable("test2") + .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull()) + .addColumn("number", "integer") + .execute(); + + await kysely.schema.createIndex("idx_test2_number").on("test2").columns(["number"]).execute(); + + const introspector = new SqliteIntrospector(kysely, {}); + + const result = await introspector.getTables(); + + //console.log(_jsonp(result)); + + expect(result).toEqual([ + { + name: "test", + isView: false, + columns: [ + { + name: "id", + dataType: "INTEGER", + isNullable: false, + isAutoIncrementing: true, + hasDefaultValue: false, + comment: undefined, + }, + { + name: "string", + dataType: "TEXT", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: false, + comment: undefined, + }, + { + comment: undefined, + dataType: "INTEGER", + hasDefaultValue: false, + isAutoIncrementing: false, + isNullable: true, + name: "number", + }, + ], + }, + { + name: "test2", + isView: false, + columns: [ + { + name: "id", + dataType: "INTEGER", + isNullable: false, + isAutoIncrementing: true, + hasDefaultValue: false, + comment: undefined, + }, + { + name: "number", + dataType: "INTEGER", + isNullable: true, + isAutoIncrementing: false, + hasDefaultValue: false, + comment: undefined, + }, + ], + }, + ]); + }); +}); diff --git a/app/__test__/data/specs/fields/Field.spec.ts b/app/__test__/data/specs/fields/Field.spec.ts index 45e4351..82ba9de 100644 --- a/app/__test__/data/specs/fields/Field.spec.ts +++ b/app/__test__/data/specs/fields/Field.spec.ts @@ -1,23 +1,29 @@ import { describe, expect, test } from "bun:test"; -import { Default, parse, stripMark } from "../../../../src/core/utils"; -import { Field, type SchemaResponse, TextField, baseFieldConfigSchema } from "../../../../src/data"; -import { runBaseFieldTests, transformPersist } from "./inc"; +import { Default, stripMark } from "../../../../src/core/utils"; +import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field"; +import { runBaseFieldTests } from "./inc"; describe("[data] Field", async () => { class FieldSpec extends Field { - schema(): SchemaResponse { - return this.useSchemaHelper("text"); - } getSchema() { return baseFieldConfigSchema; } } + test("fieldSpec", () => { + expect(new FieldSpec("test").schema()).toEqual({ + name: "test", + type: "text", + nullable: true, // always true + dflt: undefined, // never using default value + }); + }); + runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); test("default config", async () => { const config = Default(baseFieldConfigSchema, {}); - expect(stripMark(new FieldSpec("test").config)).toEqual(config); + expect(stripMark(new FieldSpec("test").config)).toEqual(config as any); }); test("transformPersist (specific)", async () => { diff --git a/app/__test__/data/specs/fields/PrimaryField.spec.ts b/app/__test__/data/specs/fields/PrimaryField.spec.ts index 5d6dd54..6be0166 100644 --- a/app/__test__/data/specs/fields/PrimaryField.spec.ts +++ b/app/__test__/data/specs/fields/PrimaryField.spec.ts @@ -10,7 +10,12 @@ describe("[data] PrimaryField", async () => { test("schema", () => { expect(field.name).toBe("primary"); - expect(field.schema()).toEqual(["primary", "integer", expect.any(Function)]); + expect(field.schema()).toEqual({ + name: "primary", + type: "integer" as const, + nullable: false, + primary: true, + }); }); test("hasDefault", async () => { diff --git a/app/__test__/data/specs/fields/inc.ts b/app/__test__/data/specs/fields/inc.ts index 1754c20..ff2d00e 100644 --- a/app/__test__/data/specs/fields/inc.ts +++ b/app/__test__/data/specs/fields/inc.ts @@ -34,11 +34,14 @@ export function runBaseFieldTests( test("schema", () => { expect(noConfigField.name).toBe("no_config"); - expect(noConfigField.schema(null as any)).toEqual([ - "no_config", - config.schemaType, - expect.any(Function), - ]); + + const { type, name, nullable, dflt } = noConfigField.schema()!; + expect({ type, name, nullable, dflt }).toEqual({ + type: config.schemaType as any, + name: "no_config", + nullable: true, // always true + dflt: undefined, // never using default value + }); }); test("hasDefault", async () => { diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index ae6454f..405e46f 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -4,6 +4,9 @@ import Database from "libsql"; import { format as sqlFormat } from "sql-formatter"; import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data"; import type { em as protoEm } from "../src/data/prototype"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { slugify } from "core/utils/strings"; export function getDummyDatabase(memory: boolean = true): { dummyDb: SqliteDatabase; @@ -71,3 +74,46 @@ export function schemaToEm(s: ReturnType, conn?: Connection): En export const assetsPath = `${import.meta.dir}/_assets`; export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; + +export async function enableFetchLogging() { + const originalFetch = global.fetch; + + global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await originalFetch(input, init); + const url = input instanceof URL || typeof input === "string" ? input : input.url; + + // Only clone if it's a supported content type + const contentType = response.headers.get("content-type") || ""; + const isSupported = + contentType.includes("json") || + contentType.includes("text") || + contentType.includes("xml"); + + if (isSupported) { + const clonedResponse = response.clone(); + let extension = "txt"; + let body: string; + + if (contentType.includes("json")) { + body = JSON.stringify(await clonedResponse.json(), null, 2); + extension = "json"; + } else if (contentType.includes("xml")) { + body = await clonedResponse.text(); + extension = "xml"; + } else { + body = await clonedResponse.text(); + } + + const fileName = `${new Date().getTime()}_${init?.method ?? "GET"}_${slugify(String(url))}.${extension}`; + const filePath = join(assetsTmpPath, fileName); + + await writeFile(filePath, body); + } + + return response; + }; + + return () => { + global.fetch = originalFetch; + }; +} diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts index 9b2bb51..298ad31 100644 --- a/app/__test__/integration/auth.integration.test.ts +++ b/app/__test__/integration/auth.integration.test.ts @@ -1,9 +1,12 @@ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { App, createApp } from "../../src"; import type { AuthResponse } from "../../src/auth"; import { auth } from "../../src/auth/middlewares"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; -import { disableConsoleLog, enableConsoleLog } from "../helper"; +import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; + +const { dummyConnection, afterAllCleanup } = getDummyConnection(); +afterEach(afterAllCleanup); beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -64,6 +67,7 @@ const configs = { function createAuthApp() { const app = createApp({ + connection: dummyConnection, initialConfig: { auth: configs.auth, }, diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 71b9cbb..3584317 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -39,8 +39,8 @@ function makeName(ext: string) { return randomString(10) + "." + ext; } -/*beforeAll(disableConsoleLog); -afterAll(enableConsoleLog);*/ +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("MediaController", () => { test.only("accepts direct", async () => { @@ -56,9 +56,9 @@ describe("MediaController", () => { console.log(result); expect(result.name).toBe(name); - /*const destFile = Bun.file(assetsTmpPath + "/" + name); + const destFile = Bun.file(assetsTmpPath + "/" + name); expect(destFile.exists()).resolves.toBe(true); - await destFile.delete();*/ + await destFile.delete(); }); test("accepts form data", async () => { diff --git a/app/__test__/media/adapters/StorageS3Adapter.spec.ts b/app/__test__/media/adapters/StorageS3Adapter.spec.ts index ad777e7..7b4a0a4 100644 --- a/app/__test__/media/adapters/StorageS3Adapter.spec.ts +++ b/app/__test__/media/adapters/StorageS3Adapter.spec.ts @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { randomString } from "../../../src/core/utils"; import { StorageS3Adapter } from "../../../src/media"; import { config } from "dotenv"; +//import { enableFetchLogging } from "../../helper"; const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } = dotenvOutput.parsed!; @@ -11,7 +12,17 @@ const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_ const ALL_TESTS = !!process.env.ALL_TESTS; console.log("ALL_TESTS?", ALL_TESTS); -describe.skipIf(true)("StorageS3Adapter", async () => { +/* +// @todo: preparation to mock s3 calls + replace fast-xml-parser +let cleanup: () => void; +beforeAll(async () => { + cleanup = await enableFetchLogging(); +}); +afterAll(() => { + cleanup(); +}); */ + +describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => { if (ALL_TESTS) return; const versions = [ @@ -66,7 +77,7 @@ describe.skipIf(true)("StorageS3Adapter", async () => { test.skipIf(disabled("putObject"))("puts an object", async () => { objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file)).toBeString(); + expect(await adapter.putObject(filename, file as any)).toBeString(); }); test.skipIf(disabled("listObjects"))("lists objects", async () => { diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 332b999..9091fbb 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -4,6 +4,7 @@ import { AuthController } from "../../src/auth/api/AuthController"; import { em, entity, make, text } from "../../src/data"; import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { disableConsoleLog, enableConsoleLog } from "../helper"; +// @ts-ignore import { makeCtx, moduleTestSuite } from "./module-test-suite"; describe("AppAuth", () => { @@ -22,7 +23,7 @@ describe("AppAuth", () => { const config = auth.toJSON(); expect(config.jwt).toBeUndefined(); - expect(config.strategies.password.config).toBeUndefined(); + expect(config.strategies?.password?.config).toBeUndefined(); }); test("enabling auth: generate secret", async () => { @@ -42,6 +43,7 @@ describe("AppAuth", () => { const auth = new AppAuth( { enabled: true, + // @ts-ignore jwt: { secret: "123456", }, @@ -75,7 +77,7 @@ describe("AppAuth", () => { const { data: users } = await ctx.em.repository("users").findMany(); expect(users.length).toBe(1); - expect(users[0].email).toBe("some@body.com"); + expect(users[0]?.email).toBe("some@body.com"); } }); @@ -157,7 +159,7 @@ describe("AppAuth", () => { 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]); + expect(field.config[prop]).toEqual(authField.config[prop]); } } }); diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index c556795..31b2027 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -43,7 +43,7 @@ describe("ModuleManager", async () => { }).toJSON(), }, }, - }); + }) as any; //const { version, ...json } = mm.toJSON() as any; const c2 = getDummyConnection(); @@ -73,7 +73,7 @@ describe("ModuleManager", async () => { }).toJSON(), }, }, - }; + } as any; //const { version, ...json } = mm.toJSON() as any; const { dummyConnection } = getDummyConnection(); @@ -90,23 +90,20 @@ describe("ModuleManager", async () => { await mm2.build(); expect(stripMark(json)).toEqual(stripMark(mm2.configs())); - expect(mm2.configs().data.entities.test).toBeDefined(); - expect(mm2.configs().data.entities.test.fields.content).toBeDefined(); - expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined(); + expect(mm2.configs().data.entities?.test).toBeDefined(); + expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined(); + expect(mm2.get("data").toJSON().entities?.test?.fields?.content).toBeDefined(); }); test("s4: config given, table exists, version outdated, migrate", async () => { const c = getDummyConnection(); const mm = new ModuleManager(c.dummyConnection); await mm.build(); - const version = mm.version(); const json = mm.configs(); const c2 = getDummyConnection(); const db = c2.dummyConnection.kysely; - const mm2 = new ModuleManager(c2.dummyConnection, { - initial: { version: version - 1, ...json }, - }); + const mm2 = new ModuleManager(c2.dummyConnection); await mm2.syncConfigTable(); await db @@ -171,7 +168,7 @@ describe("ModuleManager", async () => { expect(mm2.configs().data.basepath).toBe("/api/data2"); }); - test("blank app, modify config", async () => { + /*test("blank app, modify config", async () => { const { dummyConnection } = getDummyConnection(); const mm = new ModuleManager(dummyConnection); @@ -194,7 +191,7 @@ describe("ModuleManager", async () => { }, }, }); - }); + });*/ test("partial config given", async () => { const { dummyConnection } = getDummyConnection(); @@ -212,13 +209,15 @@ describe("ModuleManager", async () => { expect(mm.version()).toBe(CURRENT_VERSION); expect(mm.built()).toBe(true); expect(mm.configs().auth.enabled).toBe(true); - expect(mm.configs().data.entities.users).toBeDefined(); + expect(mm.configs().data.entities?.users).toBeDefined(); }); test("partial config given, but db version exists", async () => { const c = getDummyConnection(); const mm = new ModuleManager(c.dummyConnection); await mm.build(); + console.log("==".repeat(30)); + console.log(""); const json = mm.configs(); const c2 = getDummyConnection(); @@ -265,8 +264,8 @@ describe("ModuleManager", async () => { override async build() { //console.log("building FailingModule", this.config); - if (this.config.value < 0) { - throw new Error("value must be positive"); + if (this.config.value && this.config.value < 0) { + throw new Error("value must be positive, given: " + this.config.value); } this.setBuilt(); } @@ -332,6 +331,7 @@ describe("ModuleManager", async () => { // @ts-ignore const f = mm.mutateConfigSafe("failing"); + // @ts-ignore expect(f.set({ value: 2 })).resolves.toBeDefined(); expect(mockOnUpdated).toHaveBeenCalled(); }); diff --git a/app/__test__/modules/migrations/migrations.spec.ts b/app/__test__/modules/migrations/migrations.spec.ts index de004ac..4bd83ab 100644 --- a/app/__test__/modules/migrations/migrations.spec.ts +++ b/app/__test__/modules/migrations/migrations.spec.ts @@ -1,39 +1,44 @@ import { describe, expect, test } from "bun:test"; import { type InitialModuleConfigs, createApp } from "../../../src"; -import type { Kysely } from "kysely"; +import { type Kysely, sql } from "kysely"; import { getDummyConnection } from "../../helper"; import v7 from "./samples/v7.json"; +import v8 from "./samples/v8.json"; +import v8_2 from "./samples/v8-2.json"; // app expects migratable config to be present in database -async function createVersionedApp(config: InitialModuleConfigs) { +async function createVersionedApp(config: InitialModuleConfigs | any) { 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 db = dummyConnection.kysely as Kysely; + await sql`CREATE TABLE "__bknd" ( + "id" integer not null primary key autoincrement, + "version" integer, + "type" text, + "json" text, + "created_at" datetime, + "updated_at" datetime + )`.execute(db); - 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) + await db + .insertInto("__bknd") + .values({ + version, + type: "config", + created_at: new Date().toISOString(), + json: JSON.stringify(rest), + }) .execute(); - const app2 = createApp({ + const app = createApp({ connection: dummyConnection, }); - await app2.build(); - return app2; + await app.build(); + return app; } describe("Migrations", () => { @@ -46,8 +51,8 @@ describe("Migrations", () => { const app = await createVersionedApp(v7); - expect(app.version()).toBe(8); - expect(app.toJSON(true).auth.strategies.password.enabled).toBe(true); + expect(app.version()).toBeGreaterThan(7); + expect(app.toJSON(true).auth.strategies?.password?.enabled).toBe(true); const req = await app.server.request("/api/auth/password/register", { method: "POST", @@ -60,7 +65,17 @@ describe("Migrations", () => { }), }); expect(req.ok).toBe(true); - const res = await req.json(); + const res = (await req.json()) as any; expect(res.user.email).toBe("test@test.com"); }); + + test("migration from 8 to 9", async () => { + expect(v8.version).toBe(8); + + const app = await createVersionedApp(v8); + + expect(app.version()).toBeGreaterThan(8); + // @ts-expect-error + expect(app.toJSON(true).server.admin).toBeUndefined(); + }); }); diff --git a/app/__test__/modules/migrations/samples/v8-2.json b/app/__test__/modules/migrations/samples/v8-2.json new file mode 100644 index 0000000..c94e85a --- /dev/null +++ b/app/__test__/modules/migrations/samples/v8-2.json @@ -0,0 +1,709 @@ +{ + "version": 8, + "server": { + "admin": { + "basepath": "", + "color_scheme": "light", + "logo_return_path": "/" + }, + "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" + }, + "gender": { + "type": "string" + }, + "ai_description": { + "type": "string" + } + }, + "additionalProperties": { + "type": ["string", "number"] + } + }, + "required": false, + "fillable": true, + "hidden": ["table"] + } + }, + "_likes": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "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 + } + }, + "temporary": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "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": ["read", "table", "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 + } + }, + "_boards": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "product_likes": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "created_at": { + "type": "date", + "config": { + "type": "datetime", + "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": { + "sort_field": "id", + "sort_dir": "desc", + "name": "Product Likes" + } + }, + "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": false, + "fillable": true, + "hidden": false + } + }, + "description": { + "type": "text", + "config": { + "required": false, + "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 + } + }, + "_products": { + "type": "number", + "config": { + "default_value": 0, + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + }, + "boards_products": { + "type": "generated", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "boards_id": { + "type": "relation", + "config": { + "required": true, + "reference": "boards", + "target": "boards", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "products_id": { + "type": "relation", + "config": { + "required": true, + "reference": "products", + "target": "products", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "manual": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "desc" + } + } + }, + "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_boards_products_unique_boards_id_products_id": { + "entity": "boards_products", + "fields": ["boards_id", "products_id"], + "unique": true + }, + "idx_media_entity_id": { + "entity": "media", + "fields": ["entity_id"], + "unique": false + }, + "idx_products_identifier": { + "entity": "products", + "fields": ["identifier"], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "2wY76Z$JQg(3t?Wn8g,^ZqZhlmjNx<@uQjI!7i^XZBF11Xa1>zZK2??Y[D|]cc%k", + "alg": "HS256", + "fields": ["id", "email", "role"] + }, + "cookie": { + "path": "/", + "sameSite": "lax", + "secure": true, + "httpOnly": true, + "expires": 604800, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "enabled": true, + "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": "123", + "secret_access_key": "123", + "url": "https://123.r2.cloudflarestorage.com/enly" + } + } + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} diff --git a/app/__test__/modules/migrations/samples/v8.json b/app/__test__/modules/migrations/samples/v8.json new file mode 100644 index 0000000..e4bacee --- /dev/null +++ b/app/__test__/modules/migrations/samples/v8.json @@ -0,0 +1,598 @@ +{ + "version": 8, + "server": { + "admin": { + "basepath": "", + "logo_return_path": "/", + "color_scheme": "dark" + }, + "cors": { + "origin": "*", + "allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ] + } + }, + "data": { + "basepath": "/api/data", + "entities": { + "posts": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": true, + "fillable": true, + "hidden": false + } + }, + "slug": { + "type": "text", + "config": { + "html_config": { + "element": "input" + }, + "pattern": "^[a-z\\-\\_0-9]+$", + "required": false, + "fillable": true, + "hidden": false, + "label": "Slug" + } + }, + "content": { + "type": "text", + "config": { + "html_config": { + "element": "textarea", + "props": { + "rows": 5 + } + }, + "required": false, + "fillable": true, + "hidden": ["form"] + } + }, + "active": { + "type": "boolean", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "images": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "posts" + } + }, + "tags": { + "type": "jsonschema", + "config": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "ui_schema": { + "ui:options": { + "orderable": false + } + }, + "required": false, + "fillable": true, + "hidden": false + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": false, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + } + }, + "config": { + "sort_field": "title", + "sort_dir": "desc" + } + }, + "comments": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "content": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "created_at": { + "type": "date", + "config": { + "type": "date", + "required": false, + "fillable": true, + "hidden": false + } + }, + "posts_id": { + "type": "relation", + "config": { + "label": "Posts", + "required": true, + "reference": "posts", + "target": "posts", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + }, + "users_id": { + "type": "relation", + "config": { + "label": "Users", + "required": true, + "reference": "users", + "target": "users", + "target_field": "id", + "fillable": true, + "hidden": false, + "on_delete": "set null" + } + } + }, + "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", "google"] + }, + "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", "editor"] + }, + "required": false, + "fillable": true, + "hidden": false + } + }, + "avatar": { + "type": "media", + "config": { + "required": false, + "fillable": ["update"], + "hidden": false, + "mime_types": [], + "virtual": true, + "entity": "users", + "max_items": 1 + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "test": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "fillable": false, + "required": false, + "hidden": false + } + }, + "title": { + "type": "text", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + }, + "number": { + "type": "number", + "config": { + "required": false, + "fillable": true, + "hidden": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": { + "n1_comments_posts": { + "type": "n:1", + "source": "comments", + "target": "posts", + "config": { + "required": true, + "with_limit": 5 + } + }, + "poly_posts_media_images": { + "type": "poly", + "source": "posts", + "target": "media", + "config": { + "mappedBy": "images" + } + }, + "n1_posts_users": { + "type": "n:1", + "source": "posts", + "target": "users", + "config": { + "with_limit": 5 + } + }, + "n1_comments_users": { + "type": "n:1", + "source": "comments", + "target": "users", + "config": { + "required": true, + "with_limit": 5 + } + }, + "poly_users_media_avatar": { + "type": "poly", + "source": "users", + "target": "media", + "config": { + "mappedBy": "avatar", + "targetCardinality": 1 + } + } + }, + "indices": { + "idx_unique_media_path": { + "entity": "media", + "fields": ["path"], + "unique": true + }, + "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_media_reference": { + "entity": "media", + "fields": ["reference"], + "unique": false + }, + "idx_media_entity_id": { + "entity": "media", + "fields": ["entity_id"], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "jwt": { + "secret": "A%3jk*wD!Zruj123123123j$Wm8qS8m8qS8", + "alg": "HS256", + "fields": ["id", "email", "role"], + "issuer": "showoff" + }, + "guard": { + "enabled": true + }, + "strategies": { + "password": { + "enabled": true, + "type": "password", + "config": { + "hashing": "sha256" + } + }, + "google": { + "enabled": true, + "type": "oauth", + "config": { + "type": "oidc", + "client": { + "client_id": "545948917277-123ieuifrag.apps.googleusercontent.com", + "client_secret": "123-123hTTZfDDGPDPp" + }, + "name": "google" + } + } + }, + "roles": { + "guest": { + "permissions": [ + "data.entity.read", + "system.access.api", + "system.config.read" + ], + "is_default": true + }, + "admin": { + "is_default": false, + "implicit_allow": true + }, + "editor": { + "permissions": [ + "system.access.admin", + "system.config.read", + "system.schema.read", + "system.config.read.secrets", + "system.access.api", + "data.entity.read", + "data.entity.update", + "data.entity.delete", + "data.entity.create" + ] + } + }, + "allow_register": true, + "cookie": { + "path": "/", + "sameSite": "lax", + "secure": true, + "httpOnly": true, + "expires": 604800, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + } + }, + "media": { + "enabled": true, + "basepath": "/api/media", + "entity_name": "media", + "storage": {}, + "adapter": { + "type": "s3", + "config": { + "access_key": "123", + "secret_access_key": "123", + "url": "https://123.r2.cloudflarestorage.com/bknd-123" + } + } + }, + "flows": { + "basepath": "/api/flows", + "flows": { + "test": { + "trigger": { + "type": "http", + "config": { + "mode": "sync", + "method": "GET", + "response_type": "html", + "path": "/json-posts" + } + }, + "tasks": { + "fetching": { + "type": "fetch", + "params": { + "method": "GET", + "headers": [], + "url": "https://jsonplaceholder.typicode.com/posts" + } + }, + "render": { + "type": "render", + "params": { + "render": "

Posts

\n
    \n {% for post in fetching.output %}\n
  • {{ post.title }}
  • \n {% endfor %}\n
" + } + } + }, + "connections": { + "5cce66b5-57c6-4541-88ac-b298794c6c52": { + "source": "fetching", + "target": "render", + "config": { + "condition": { + "type": "success" + } + } + } + }, + "start_task": "fetching", + "responding_task": "render" + } + } + } +} diff --git a/app/bknd.config.js.ignore b/app/bknd.config.js.ignore deleted file mode 100644 index 5f279d3..0000000 --- a/app/bknd.config.js.ignore +++ /dev/null @@ -1,13 +0,0 @@ -//import type { BkndConfig } from "./src"; - -export default { - app: { - connection: { - type: "libsql", - config: { - //url: "http://localhost:8080" - url: ":memory:" - } - } - } -}; diff --git a/app/build.esbuild.ts b/app/build.esbuild.ts deleted file mode 100644 index d9fa6f4..0000000 --- a/app/build.esbuild.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { $, type Subprocess } from "bun"; -import * as esbuild from "esbuild"; -import postcss from "esbuild-postcss"; -import { entryOutputMeta } from "./internal/esbuild.entry-output-meta.plugin"; -import { guessMimeType } from "./src/media/storage/mime-types"; - -const args = process.argv.slice(2); -const watch = args.includes("--watch"); -const minify = args.includes("--minify"); -const types = args.includes("--types"); -const sourcemap = args.includes("--sourcemap"); - -type BuildOptions = esbuild.BuildOptions & { name: string }; - -const baseOptions: Partial> & { plugins?: any[] } = { - minify, - sourcemap, - metafile: true, - format: "esm", - drop: ["console", "debugger"], - loader: { - ".svg": "dataurl", - }, - define: { - __isDev: "0", - }, -}; - -// @ts-ignore -type BuildFn = (format?: "esm" | "cjs") => BuildOptions; - -// build BE -const builds: Record = { - backend: (format = "esm") => ({ - ...baseOptions, - name: `backend ${format}`, - entryPoints: [ - "src/index.ts", - "src/data/index.ts", - "src/core/index.ts", - "src/core/utils/index.ts", - "src/ui/index.ts", - "src/ui/main.css", - ], - outdir: "dist", - outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, - platform: "browser", - splitting: false, - bundle: true, - plugins: [postcss()], - //target: "es2022", - format, - }), - /*components: (format = "esm") => ({ - ...baseOptions, - name: `components ${format}`, - entryPoints: ["src/ui/index.ts", "src/ui/main.css"], - outdir: "dist/ui", - outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, - format, - platform: "browser", - splitting: false, - //target: "es2022", - bundle: true, - //external: ["react", "react-dom", "@tanstack/react-query-devtools"], - plugins: [postcss()], - loader: { - ".svg": "dataurl", - ".js": "jsx" - } - }),*/ - static: (format = "esm") => ({ - ...baseOptions, - name: `static ${format}`, - entryPoints: ["src/ui/main.tsx", "src/ui/main.css"], - entryNames: "[dir]/[name]-[hash]", - outdir: "dist/static", - outExtension: { ".js": format === "esm" ? ".js" : ".cjs" }, - platform: "browser", - bundle: true, - splitting: true, - inject: ["src/ui/inject.js"], - target: "es2022", - format, - loader: { - ".svg": "dataurl", - ".js": "jsx", - }, - define: { - __isDev: "0", - "process.env.NODE_ENV": '"production"', - }, - chunkNames: "chunks/[name]-[hash]", - plugins: [ - postcss(), - entryOutputMeta(async (info) => { - const manifest: Record = {}; - const toAsset = (output: string) => { - const name = output.split("/").pop()!; - return { - name, - path: output, - mime: guessMimeType(name), - }; - }; - for (const { output, meta } of info) { - manifest[meta.entryPoint as string] = toAsset(output); - if (meta.cssBundle) { - manifest["src/ui/main.css"] = toAsset(meta.cssBundle); - } - } - - const manifest_file = "dist/static/manifest.json"; - await Bun.write(manifest_file, JSON.stringify(manifest, null, 2)); - console.log(`Manifest written to ${manifest_file}`, manifest); - }), - ], - }), -}; - -function adapter(adapter: string, overrides: Partial = {}): BuildOptions { - return { - ...baseOptions, - name: `adapter ${adapter} ${overrides?.format === "cjs" ? "cjs" : "esm"}`, - entryPoints: [`src/adapter/${adapter}`], - platform: "neutral", - outfile: `dist/adapter/${adapter}/index.${overrides?.format === "cjs" ? "cjs" : "js"}`, - external: [ - "cloudflare:workers", - "@hono*", - "hono*", - "bknd*", - "*.html", - "node*", - "react*", - "next*", - "libsql", - "@libsql*", - ], - splitting: false, - treeShaking: true, - bundle: true, - ...overrides, - }; -} -const adapters = [ - adapter("vite", { platform: "node" }), - adapter("cloudflare"), - adapter("nextjs", { platform: "node", format: "esm" }), - adapter("nextjs", { platform: "node", format: "cjs" }), - adapter("remix", { format: "esm" }), - adapter("remix", { format: "cjs" }), - adapter("bun"), - adapter("node", { platform: "node", format: "esm" }), - adapter("node", { platform: "node", format: "cjs" }), -]; - -const collect = [ - builds.static(), - builds.backend(), - //builds.components(), - builds.backend("cjs"), - //builds.components("cjs"), - ...adapters, -]; - -if (watch) { - const _state: { - timeout: Timer | undefined; - cleanup: Subprocess | undefined; - building: Subprocess | undefined; - } = { - timeout: undefined, - cleanup: undefined, - building: undefined, - }; - - async function rebuildTypes() { - if (!types) return; - if (_state.timeout) { - clearTimeout(_state.timeout); - if (_state.cleanup) _state.cleanup.kill(); - if (_state.building) _state.building.kill(); - } - _state.timeout = setTimeout(async () => { - _state.cleanup = Bun.spawn(["bun", "clean:types"], { - onExit: () => { - _state.cleanup = undefined; - _state.building = Bun.spawn(["bun", "build:types"], { - onExit: () => { - _state.building = undefined; - console.log("Types rebuilt"); - }, - }); - }, - }); - }, 1000); - } - - for (const { name, ...build } of collect) { - const ctx = await esbuild.context({ - ...build, - plugins: [ - ...(build.plugins ?? []), - { - name: "rebuild-notify", - setup(build) { - build.onEnd((result) => { - console.log(`rebuilt ${name} with ${result.errors.length} errors`); - rebuildTypes(); - }); - }, - }, - ], - }); - ctx.watch(); - } -} else { - await $`rm -rf dist`; - - async function _build() { - let i = 0; - const count = collect.length; - for await (const { name, ...build } of collect) { - await esbuild.build({ - ...build, - plugins: [ - ...(build.plugins || []), - { - name: "progress", - setup(build) { - i++; - build.onEnd((result) => { - const errors = result.errors.length; - const from = String(i).padStart(String(count).length); - console.log(`[${from}/${count}] built ${name} with ${errors} errors`); - }); - }, - }, - ], - }); - } - - console.log("All builds complete"); - } - - async function _buildtypes() { - if (!types) return; - Bun.spawn(["bun", "build:types"], { - onExit: () => { - console.log("Types rebuilt"); - }, - }); - } - - await Promise.all([_build(), _buildtypes()]); -} diff --git a/app/build.ts b/app/build.ts index 6f7872a..1720b2c 100644 --- a/app/build.ts +++ b/app/build.ts @@ -46,17 +46,28 @@ if (types && !watch) { buildTypes(); } +function banner(title: string) { + console.log(""); + console.log("=".repeat(40)); + console.log(title.toUpperCase()); + console.log("-".repeat(40)); +} + +// collection of always-external packages +const external = ["bun:test", "@libsql/client"] as const; + /** * Building backend and general API */ async function buildApi() { + banner("Building API"); await tsup.build({ minify, sourcemap, watch, entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], outDir: "dist", - external: ["bun:test", "@libsql/client"], + external: [...external], metafile: true, platform: "browser", format: ["esm"], @@ -85,7 +96,7 @@ async function buildUi() { sourcemap, watch, external: [ - "bun:test", + ...external, "react", "react-dom", "react/jsx-runtime", @@ -109,6 +120,7 @@ async function buildUi() { }, } satisfies tsup.Options; + banner("Building UI"); await tsup.build({ ...base, entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], @@ -119,6 +131,7 @@ async function buildUi() { }, }); + banner("Building Client"); await tsup.build({ ...base, entry: ["src/ui/client/index.ts"], @@ -136,6 +149,7 @@ async function buildUi() { * - ui/client is external, and after built replaced with "bknd/client" */ async function buildUiElements() { + banner("Building UI Elements"); await tsup.build({ minify, sourcemap, @@ -205,6 +219,7 @@ function baseConfig(adapter: string, overrides: Partial = {}): tsu } async function buildAdapters() { + banner("Building Adapters"); // base adapter handles await tsup.build({ ...baseConfig(""), @@ -213,7 +228,7 @@ async function buildAdapters() { }); // specific adatpers - await tsup.build(baseConfig("remix")); + await tsup.build(baseConfig("react-router")); await tsup.build(baseConfig("bun")); await tsup.build(baseConfig("astro")); await tsup.build(baseConfig("aws")); diff --git a/app/package.json b/app/package.json index 513b778..445cc09 100644 --- a/app/package.json +++ b/app/package.json @@ -3,8 +3,8 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.9.1", - "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", + "version": "0.10.0", + "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { "type": "git", @@ -19,7 +19,7 @@ "test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", - "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", + "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify", "build:static": "vite build", "watch": "bun run build.ts --types --watch", "types": "bun tsc -p tsconfig.build.json --noEmit", @@ -32,81 +32,84 @@ }, "license": "FSL-1.1-MIT", "dependencies": { - "@cfworker/json-schema": "^2.0.1", + "@cfworker/json-schema": "^4.1.1", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-liquid": "^6.2.1", - "@hello-pangea/dnd": "^17.0.0", + "@codemirror/lang-liquid": "^6.2.2", + "@hello-pangea/dnd": "^18.0.1", "@libsql/client": "^0.14.0", - "@mantine/core": "^7.13.4", - "@sinclair/typebox": "^0.32.34", - "@tanstack/react-form": "0.19.2", - "@uiw/react-codemirror": "^4.23.6", - "@xyflow/react": "^12.3.2", - "aws4fetch": "^1.0.18", + "@mantine/core": "^7.17.1", + "@mantine/hooks": "^7.17.1", + "@sinclair/typebox": "^0.34.30", + "@tanstack/react-form": "^1.0.5", + "@uiw/react-codemirror": "^4.23.10", + "@xyflow/react": "^12.4.4", + "aws4fetch": "^1.0.20", "dayjs": "^1.11.13", - "fast-xml-parser": "^4.4.0", - "hono": "^4.6.12", + "fast-xml-parser": "^5.0.8", + "hono": "^4.7.4", "json-schema-form-react": "^0.0.2", "json-schema-library": "^10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "kysely": "^0.27.4", - "liquidjs": "^10.15.0", + "kysely": "^0.27.6", + "liquidjs": "^10.21.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "picocolors": "^1.1.1", - "radix-ui": "^1.1.2", - "swr": "^2.2.5" + "radix-ui": "^1.1.3", + "swr": "^2.3.3" }, "devDependencies": { - "@aws-sdk/client-s3": "^3.613.0", + "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", "@dagrejs/dagre": "^1.1.4", - "@mantine/modals": "^7.13.4", - "@mantine/notifications": "^7.13.4", - "@hono/typebox-validator": "^0.2.6", - "@hono/vite-dev-server": "^0.17.0", - "@hono/zod-validator": "^0.4.1", - "@hookform/resolvers": "^3.9.1", + "@hono/typebox-validator": "^0.3.2", + "@hono/vite-dev-server": "^0.19.0", + "@hookform/resolvers": "^4.1.3", "@libsql/kysely-libsql": "^0.4.1", + "@mantine/modals": "^7.17.1", + "@mantine/notifications": "^7.17.1", "@rjsf/core": "5.22.2", "@tabler/icons-react": "3.18.0", - "@types/node": "^22.10.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.3", - "autoprefixer": "^10.4.20", + "@tailwindcss/postcss": "^4.0.12", + "@tailwindcss/vite": "^4.0.12", + "@types/node": "^22.13.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.21", "clsx": "^2.1.1", "dotenv": "^16.4.7", - "esbuild-postcss": "^0.0.4", - "jotai": "^2.10.1", + "jotai": "^2.12.2", "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", - "postcss": "^8.4.47", + "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "react-hook-form": "^7.53.1", + "posthog-js-lite": "^3.4.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "react-icons": "5.2.1", - "react-json-view-lite": "^2.0.1", - "sql-formatter": "^15.4.9", - "tailwind-merge": "^2.5.4", - "tailwindcss": "^3.4.14", + "react-json-view-lite": "^2.4.1", + "sql-formatter": "^15.4.11", + "tailwind-merge": "^3.0.2", + "tailwindcss": "^4.0.12", "tailwindcss-animate": "^1.0.7", - "tsc-alias": "^1.8.10", - "tsup": "^8.3.5", - "vite": "^5.4.10", - "vite-plugin-static-copy": "^2.0.0", - "vite-tsconfig-paths": "^5.0.1", - "wouter": "^3.3.5" + "tsc-alias": "^1.8.11", + "tsup": "^8.4.0", + "vite": "^6.2.1", + "vite-tsconfig-paths": "^5.1.4", + "wouter": "^3.6.0" }, "optionalDependencies": { - "@hono/node-server": "^1.13.7" + "@hono/node-server": "^1.13.8" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "react": ">=19", + "react-dom": ">=19" }, "main": "./dist/index.js", "module": "./dist/index.js", @@ -171,10 +174,10 @@ "import": "./dist/adapter/nextjs/index.js", "require": "./dist/adapter/nextjs/index.cjs" }, - "./adapter/remix": { - "types": "./dist/types/adapter/remix/index.d.ts", - "import": "./dist/adapter/remix/index.js", - "require": "./dist/adapter/remix/index.cjs" + "./adapter/react-router": { + "types": "./dist/types/adapter/react-router/index.d.ts", + "import": "./dist/adapter/react-router/index.js", + "require": "./dist/adapter/react-router/index.cjs" }, "./adapter/bun": { "types": "./dist/types/adapter/bun/index.d.ts", @@ -224,8 +227,9 @@ "cloudflare", "nextjs", "remix", + "react-router", "astro", "bun", "node" ] -} \ No newline at end of file +} diff --git a/app/postcss.config.js b/app/postcss.config.js index 5633993..61ce526 100644 --- a/app/postcss.config.js +++ b/app/postcss.config.js @@ -1,9 +1,6 @@ export default { plugins: { - "postcss-import": {}, - "tailwindcss/nesting": {}, - tailwindcss: {}, - autoprefixer: {}, + "@tailwindcss/postcss": {}, "postcss-preset-mantine": {}, "postcss-simple-vars": { variables: { diff --git a/app/src/adapter/cloudflare/D1Connection.ts b/app/src/adapter/cloudflare/D1Connection.ts index 768ca44..5b4b059 100644 --- a/app/src/adapter/cloudflare/D1Connection.ts +++ b/app/src/adapter/cloudflare/D1Connection.ts @@ -18,6 +18,10 @@ class CustomD1Dialect extends D1Dialect { } export class D1Connection extends SqliteConnection { + protected override readonly supported = { + batching: true, + }; + constructor(private config: D1ConnectionConfig) { const plugins = [new ParseJSONResultsPlugin()]; @@ -28,14 +32,6 @@ export class D1Connection extends SqliteConnection { super(kysely, {}, plugins); } - override supportsBatching(): boolean { - return true; - } - - override supportsIndices(): boolean { - return true; - } - protected override async batch( queries: [...Queries], ): Promise<{ diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 5c2a364..32ff102 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,15 +1,22 @@ import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import { getRuntimeKey, isNode } from "core/utils"; +import { isNode } from "core/utils"; export type NextjsBkndConfig = FrameworkBkndConfig & { cleanRequest?: { searchParams?: string[] }; }; +type NextjsContext = { + env: Record; +}; + let app: App; let building: boolean = false; -export async function getApp(config: NextjsBkndConfig) { +export async function getApp( + config: NextjsBkndConfig, + args?: Args, +) { if (building) { while (building) { await new Promise((resolve) => setTimeout(resolve, 5)); @@ -19,7 +26,7 @@ export async function getApp(config: NextjsBkndConfig) { building = true; if (!app) { - app = await createFrameworkApp(config); + app = await createFrameworkApp(config, args); await app.build(); } building = false; @@ -52,7 +59,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) { return async (req: Request) => { if (!app) { - app = await getApp(config); + app = await getApp(config, { env: process.env ?? {} }); } const request = getCleanRequest(req, cleanRequest); return app.fetch(request); diff --git a/app/src/adapter/react-router/index.ts b/app/src/adapter/react-router/index.ts new file mode 100644 index 0000000..e1e917b --- /dev/null +++ b/app/src/adapter/react-router/index.ts @@ -0,0 +1 @@ +export * from "./react-router.adapter"; diff --git a/app/src/adapter/remix/remix.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts similarity index 61% rename from app/src/adapter/remix/remix.adapter.ts rename to app/src/adapter/react-router/react-router.adapter.ts index 0820133..7e796c6 100644 --- a/app/src/adapter/remix/remix.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -1,18 +1,17 @@ import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -export type RemixBkndConfig = FrameworkBkndConfig; - -type RemixContext = { +type ReactRouterContext = { request: Request; }; +export type ReactRouterBkndConfig = FrameworkBkndConfig; let app: App; let building: boolean = false; -export async function getApp( - config: RemixBkndConfig, - args?: Args +export async function getApp( + config: ReactRouterBkndConfig, + args?: Args, ) { if (building) { while (building) { @@ -30,8 +29,8 @@ export async function getApp( return app; } -export function serve( - config: RemixBkndConfig = {}, +export function serve( + config: ReactRouterBkndConfig = {}, ) { return async (args: Args) => { app = await getApp(config, args); diff --git a/app/src/adapter/remix/AdminPage.tsx b/app/src/adapter/remix/AdminPage.tsx deleted file mode 100644 index 5c9e90b..0000000 --- a/app/src/adapter/remix/AdminPage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useAuth } from "bknd/client"; -import type { BkndAdminProps } from "bknd/ui"; -import { Suspense, lazy, useEffect, useState } from "react"; - -export function adminPage(props?: BkndAdminProps) { - const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin }))); - return () => { - const auth = useAuth(); - const [loaded, setLoaded] = useState(false); - useEffect(() => { - if (typeof window === "undefined") return; - setLoaded(true); - }, []); - if (!loaded) return null; - - return ( - - - - ); - }; -} diff --git a/app/src/adapter/remix/index.ts b/app/src/adapter/remix/index.ts deleted file mode 100644 index e02c2c0..0000000 --- a/app/src/adapter/remix/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./remix.adapter"; -export * from "./AdminPage"; diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts index f1f9a5e..478362f 100644 --- a/app/src/cli/commands/create/create.ts +++ b/app/src/cli/commands/create/create.ts @@ -9,6 +9,7 @@ import { env } from "core"; import color from "picocolors"; import { overridePackageJson, updateBkndPackages } from "./npm"; import { type Template, templates } from "./templates"; +import { createScoped, flush } from "cli/utils/telemetry"; const config = { types: { @@ -23,7 +24,7 @@ const config = { }, framework: { nextjs: "Next.js", - remix: "Remix", + "react-router": "React Router", astro: "Astro", }, } as const; @@ -48,8 +49,16 @@ function errorOutro() { process.exit(1); } +async function onExit() { + await flush(); +} + async function action(options: { template?: string; dir?: string; integration?: string }) { console.log(""); + const $t = createScoped("create"); + $t.capture("start", { + options, + }); const downloadOpts = { dir: options.dir || "./", @@ -68,6 +77,7 @@ async function action(options: { template?: string; dir?: string; integration?: })(), ); + $t.properties.at = "dir"; if (!options.dir) { const dir = await $p.text({ message: "Where to create your project?", @@ -75,24 +85,29 @@ async function action(options: { template?: string; dir?: string; integration?: initialValue: downloadOpts.dir, }); if ($p.isCancel(dir)) { + await onExit(); process.exit(1); } downloadOpts.dir = dir || "./"; } + $t.properties.at = "dir"; if (fs.existsSync(downloadOpts.dir)) { const clean = await $p.confirm({ message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, initialValue: false, }); if ($p.isCancel(clean)) { + await onExit(); process.exit(1); } downloadOpts.clean = clean; + $t.properties.clean = clean; } + // don't track name for privacy let name = downloadOpts.dir.includes("/") ? downloadOpts.dir.split("/").pop() : downloadOpts.dir.replace(/[./]/g, ""); @@ -100,13 +115,17 @@ async function action(options: { template?: string; dir?: string; integration?: if (!name || name.length === 0) name = "bknd"; let template: Template | undefined; + if (options.template) { + $t.properties.at = "template"; template = templates.find((t) => t.key === options.template) as Template; if (!template) { + await onExit(); $p.log.error(`Template ${color.cyan(options.template)} not found`); process.exit(1); } } else { + $t.properties.at = "integration"; let integration: string | undefined = options.integration; if (!integration) { await $p.stream.info( @@ -128,8 +147,10 @@ async function action(options: { template?: string; dir?: string; integration?: }); if ($p.isCancel(type)) { + await onExit(); process.exit(1); } + $t.properties.type = type; const _integration = await $p.select({ message: `Which ${color.cyan(config.types[type])} do you want to continue with?`, @@ -139,11 +160,14 @@ async function action(options: { template?: string; dir?: string; integration?: })) as any, }); if ($p.isCancel(_integration)) { + await onExit(); process.exit(1); } integration = String(_integration); + $t.properties.integration = integration; } if (!integration) { + await onExit(); $p.log.error("No integration selected"); process.exit(1); } @@ -152,15 +176,18 @@ async function action(options: { template?: string; dir?: string; integration?: const choices = templates.filter((t) => t.integration === integration); if (choices.length === 0) { + await onExit(); $p.log.error(`No templates found for "${color.cyan(String(integration))}"`); process.exit(1); } else if (choices.length > 1) { + $t.properties.at = "template"; const selected_template = await $p.select({ message: "Pick a template", options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description })), }); if ($p.isCancel(selected_template)) { + await onExit(); process.exit(1); } @@ -170,10 +197,12 @@ async function action(options: { template?: string; dir?: string; integration?: } } if (!template) { + await onExit(); $p.log.error("No template selected"); process.exit(1); } + $t.properties.template = template.key; const ctx = { template, dir: downloadOpts.dir, name }; { @@ -182,6 +211,8 @@ async function action(options: { template?: string; dir?: string; integration?: $p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(given)); }, }); + $t.properties.ref = ref; + $t.capture("used"); const prefix = template.ref === true @@ -191,7 +222,6 @@ async function action(options: { template?: string; dir?: string; integration?: : ""; const url = `${template.path}${prefix}`; - //console.log("url", url); const s = $p.spinner(); await s.start("Downloading template..."); try { @@ -234,8 +264,10 @@ async function action(options: { template?: string; dir?: string; integration?: }); if ($p.isCancel(install)) { + await onExit(); process.exit(1); } else if (install) { + $t.properties.install = true; const install_cmd = template.scripts?.install || "npm install"; const s = $p.spinner(); @@ -259,6 +291,7 @@ async function action(options: { template?: string; dir?: string; integration?: await template.postinstall(ctx); } } else { + $t.properties.install = false; await $p.stream.warn( (async function* () { yield* typewriter( @@ -291,5 +324,6 @@ If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discor })(), ); + $t.capture("complete"); $p.outro(color.green("Setup complete.")); } diff --git a/app/src/cli/commands/create/templates/index.ts b/app/src/cli/commands/create/templates/index.ts index f3cf9b5..563e955 100644 --- a/app/src/cli/commands/create/templates/index.ts +++ b/app/src/cli/commands/create/templates/index.ts @@ -1,6 +1,4 @@ import { cloudflare } from "./cloudflare"; -import { nextjs } from "./nextjs"; -import { remix } from "./remix"; export type TemplateSetupCtx = { template: Template; @@ -13,7 +11,7 @@ export type Integration = | "bun" | "cloudflare" | "nextjs" - | "remix" + | "react-router" | "astro" | "aws" | "custom"; @@ -43,8 +41,6 @@ export type Template = { export const templates: Template[] = [ cloudflare, - nextjs, - remix, { key: "node", title: "Node.js Basic", @@ -61,6 +57,14 @@ export const templates: Template[] = [ path: "gh:bknd-io/bknd/examples/bun", ref: true, }, + { + key: "nextjs", + title: "Next.js Basic", + integration: "nextjs", + description: "A basic bknd Next.js starter", + path: "gh:bknd-io/bknd/examples/nextjs", + ref: true, + }, { key: "astro", title: "Astro Basic", @@ -69,6 +73,14 @@ export const templates: Template[] = [ path: "gh:bknd-io/bknd/examples/astro", ref: true, }, + { + key: "react-router", + title: "React Router Basic", + integration: "react-router", + description: "A basic bknd React Router starter", + path: "gh:bknd-io/bknd/examples/react-router", + ref: true, + }, { key: "aws", title: "AWS Lambda Basic", diff --git a/app/src/cli/commands/create/templates/nextjs.ts b/app/src/cli/commands/create/templates/nextjs.ts deleted file mode 100644 index 94b4b58..0000000 --- a/app/src/cli/commands/create/templates/nextjs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { overridePackageJson } from "cli/commands/create/npm"; -import type { Template } from "."; - -// @todo: add `concurrently`? -export const nextjs = { - key: "nextjs", - title: "Next.js Basic", - integration: "nextjs", - description: "A basic bknd Next.js starter", - path: "gh:bknd-io/bknd/examples/nextjs", - scripts: { - install: "npm install --force", - }, - ref: true, - preinstall: async (ctx) => { - // locally it's required to overwrite react, here it is not - await overridePackageJson( - (pkg) => ({ - ...pkg, - dependencies: { - ...pkg.dependencies, - react: undefined, - "react-dom": undefined, - }, - }), - { dir: ctx.dir }, - ); - }, -} as const satisfies Template; diff --git a/app/src/cli/commands/create/templates/remix.ts b/app/src/cli/commands/create/templates/remix.ts deleted file mode 100644 index 3eef651..0000000 --- a/app/src/cli/commands/create/templates/remix.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { overridePackageJson } from "cli/commands/create/npm"; -import type { Template } from "."; - -export const remix = { - key: "remix", - title: "Remix Basic", - integration: "remix", - description: "A basic bknd Remix starter", - path: "gh:bknd-io/bknd/examples/remix", - ref: true, - preinstall: async (ctx) => { - // locally it's required to overwrite react - await overridePackageJson( - (pkg) => ({ - ...pkg, - dependencies: { - ...pkg.dependencies, - react: "^18.2.0", - "react-dom": "^18.2.0", - }, - }), - { dir: ctx.dir }, - ); - }, -} as const satisfies Template; diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index ddfeb1a..ff15f10 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -22,6 +22,7 @@ const isBun = typeof Bun !== "undefined"; export const run: CliCommand = (program) => { program .command("run") + .description("run an instance") .addOption( new Option("-p, --port ", "port to run on") .env("PORT") diff --git a/app/src/cli/index.ts b/app/src/cli/index.ts index d749df8..352cdd4 100644 --- a/app/src/cli/index.ts +++ b/app/src/cli/index.ts @@ -4,21 +4,36 @@ import { Command } from "commander"; import color from "picocolors"; import * as commands from "./commands"; import { getVersion } from "./utils/sys"; +import { capture, flush, init } from "cli/utils/telemetry"; const program = new Command(); export async function main() { + await init(); + capture("start"); + const version = await getVersion(); program .name("bknd") .description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`))) - .version(version); + .version(version) + .hook("preAction", (thisCommand, actionCommand) => { + capture(`cmd_${actionCommand.name()}`); + }) + .hook("postAction", async () => { + await flush(); + }); // register commands for (const command of Object.values(commands)) { command(program); } - program.parse(); + await program.parseAsync(); } -main().then(null).catch(console.error); +main() + .then(null) + .catch(async (e) => { + await flush(); + console.error(e); + }); diff --git a/app/src/cli/utils/telemetry.ts b/app/src/cli/utils/telemetry.ts new file mode 100644 index 0000000..9fddb42 --- /dev/null +++ b/app/src/cli/utils/telemetry.ts @@ -0,0 +1,79 @@ +import { PostHog } from "posthog-js-lite"; +import { getVersion } from "cli/utils/sys"; +import { $console, env, isDebug } from "core"; + +type Properties = { [p: string]: any }; + +let posthog: PostHog | null = null; +let version: string | null = null; + +const is_debug = isDebug() || !!process.env.LOCAL; +const enabled = env("cli_telemetry", !is_debug); + +export async function init(): Promise { + try { + if (!enabled) { + $console.debug("telemetry disabled"); + return false; + } + + $console.debug("init telemetry"); + if (!posthog) { + posthog = new PostHog(process.env.PUBLIC_POSTHOG_KEY!, { + host: process.env.PUBLIC_POSTHOG_HOST!, + disabled: !enabled, + }); + } + version = await getVersion(); + return true; + } catch (e) { + $console.debug("failed to initialize telemetry", e); + } + + return false; +} + +export function client(): PostHog { + if (!posthog) { + throw new Error("PostHog client not initialized. Call init() first."); + } + + return posthog; +} + +export function capture(event: string, properties: Properties = {}): void { + try { + if (!enabled) return; + + const name = `cli_${event}`; + const props = { + ...properties, + version: version!, + }; + $console.debug(`capture "${name}"`, props); + client().capture(name, props); + } catch (e) { + $console.debug("failed to capture telemetry", e); + } +} + +export function createScoped(scope: string, p: Properties = {}) { + const properties = p; + const _capture = (event: string, props: Properties = {}) => { + return capture(`${scope}_${event}`, { ...properties, ...props }); + }; + return { capture: _capture, properties }; +} + +export async function flush() { + try { + if (!enabled) return; + + $console.debug("flush telemetry"); + if (posthog) { + await posthog.flush(); + } + } catch (e) { + $console.debug("failed to flush telemetry", e); + } +} diff --git a/app/src/core/env.ts b/app/src/core/env.ts index 0dd60ec..6d4f0ba 100644 --- a/app/src/core/env.ts +++ b/app/src/core/env.ts @@ -1,7 +1,7 @@ export type Env = {}; -export const is_toggled = (given: unknown): boolean => { - return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(given); +export const is_toggled = (given: unknown, fallback?: boolean): boolean => { + return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(given || fallback); }; export function isDebug(): boolean { @@ -34,6 +34,16 @@ const envs = { return typeof v === "string" ? v : undefined; }, }, + // cli telemetry + cli_telemetry: { + key: "BKND_CLI_TELEMETRY", + validate: (v: unknown): boolean | undefined => { + if (typeof v === "undefined") { + return undefined; + } + return is_toggled(v, true); + }, + }, // module manager debug: { modules_debug: { key: "BKND_MODULES_DEBUG", diff --git a/app/src/core/utils/DebugLogger.ts b/app/src/core/utils/DebugLogger.ts index e58b45d..23a1b44 100644 --- a/app/src/core/utils/DebugLogger.ts +++ b/app/src/core/utils/DebugLogger.ts @@ -30,7 +30,7 @@ export class DebugLogger { const now = performance.now(); const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last)); - const indents = " ".repeat(this._context.length); + const indents = " ".repeat(Math.max(this._context.length - 1, 0)); const context = this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; console.log(indents, context, time, ...args); diff --git a/app/src/core/utils/numbers.ts b/app/src/core/utils/numbers.ts index 1435f68..33394f6 100644 --- a/app/src/core/utils/numbers.ts +++ b/app/src/core/utils/numbers.ts @@ -3,3 +3,11 @@ export function clampNumber(value: number, min: number, max: number): number { const upper = Math.max(min, max); return Math.max(lower, Math.min(value, upper)); } + +export function ensureInt(value?: string | number | null | undefined): number { + if (value === undefined || value === null) { + return 0; + } + + return typeof value === "number" ? value : Number.parseInt(value, 10); +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index a28d7ea..be115f5 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -118,3 +118,17 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean } return false; } + +export function slugify(str: string): string { + return ( + String(str) + .normalize("NFKD") // split accented characters into their base characters and diacritical marks + // biome-ignore lint/suspicious/noMisleadingCharacterClass: + .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block. + .trim() // trim leading or trailing whitespace + .toLowerCase() // convert to lowercase + .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters + .replace(/\s+/g, "-") // replace spaces with hyphens + .replace(/-+/g, "-") // remove consecutive hyphens + ); +} diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 185ee09..22fad1b 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -19,8 +19,6 @@ export class AppData extends Module { 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); }); @@ -60,7 +58,6 @@ export class AppData extends Module { ); this.ctx.guard.registerPermissions(Object.values(DataPermissions)); - this.ctx.logger.clear(); this.setBuilt(); } diff --git a/app/src/data/connection/BaseIntrospector.ts b/app/src/data/connection/BaseIntrospector.ts new file mode 100644 index 0000000..e96a44d --- /dev/null +++ b/app/src/data/connection/BaseIntrospector.ts @@ -0,0 +1,75 @@ +import { + type DatabaseMetadata, + type DatabaseMetadataOptions, + type Kysely, + type KyselyPlugin, + type RawBuilder, + type TableMetadata, + type DatabaseIntrospector, + type SchemaMetadata, + ParseJSONResultsPlugin, + DEFAULT_MIGRATION_TABLE, + DEFAULT_MIGRATION_LOCK_TABLE, +} from "kysely"; +import { KyselyPluginRunner } from "data/plugins/KyselyPluginRunner"; +import type { IndexMetadata } from "data/connection/Connection"; + +export type TableSpec = TableMetadata & { + indices: IndexMetadata[]; +}; +export type SchemaSpec = TableSpec[]; + +export type BaseIntrospectorConfig = { + excludeTables?: string[]; + plugins?: KyselyPlugin[]; +}; + +export abstract class BaseIntrospector implements DatabaseIntrospector { + readonly _excludeTables: string[] = []; + readonly _plugins: KyselyPlugin[]; + + constructor( + protected readonly db: Kysely, + config: BaseIntrospectorConfig = {}, + ) { + this._excludeTables = config.excludeTables ?? []; + this._plugins = config.plugins ?? [new ParseJSONResultsPlugin()]; + } + + abstract getSchemaSpec(): Promise; + abstract getSchemas(): Promise; + + protected getExcludedTableNames(): string[] { + return [...this._excludeTables, DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]; + } + + protected async executeWithPlugins(query: RawBuilder): Promise { + const result = await query.execute(this.db); + const runner = new KyselyPluginRunner(this._plugins ?? []); + return (await runner.transformResultRows(result.rows)) as unknown as T; + } + + async getMetadata(options?: DatabaseMetadataOptions): Promise { + return { + tables: await this.getTables(options), + }; + } + + async getIndices(tbl_name?: string): Promise { + const schema = await this.getSchemaSpec(); + return schema + .flatMap((table) => table.indices) + .filter((index) => !tbl_name || index.table === tbl_name); + } + + async getTables( + options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, + ): Promise { + const schema = await this.getSchemaSpec(); + return schema.map((table) => ({ + name: table.name, + isView: table.isView, + columns: table.columns, + })); + } +} diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index 2a3933b..ce521e1 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -1,15 +1,18 @@ import { type AliasableExpression, - type DatabaseIntrospector, + type ColumnBuilderCallback, + type ColumnDataType, type Expression, type Kysely, type KyselyPlugin, + type OnModifyForeignAction, type RawBuilder, type SelectQueryBuilder, type SelectQueryNode, type Simplify, sql, } from "kysely"; +import type { BaseIntrospector } from "./BaseIntrospector"; export type QB = SelectQueryBuilder; @@ -20,15 +23,43 @@ export type IndexMetadata = { columns: { name: string; order: number }[]; }; -export interface ConnectionIntrospector extends DatabaseIntrospector { - getIndices(tbl_name?: string): Promise; -} - export interface SelectQueryBuilderExpression extends AliasableExpression { get isSelectQueryBuilder(): true; toOperationNode(): SelectQueryNode; } +export type SchemaResponse = [string, ColumnDataType, ColumnBuilderCallback] | undefined; + +const FieldSpecTypes = [ + "text", + "integer", + "real", + "blob", + "date", + "datetime", + "timestamp", + "boolean", + "json", +] as const; + +export type FieldSpec = { + type: (typeof FieldSpecTypes)[number]; + name: string; + nullable?: boolean; + dflt?: any; + unique?: boolean; + primary?: boolean; + references?: string; + onDelete?: OnModifyForeignAction; + onUpdate?: OnModifyForeignAction; +}; + +export type IndexSpec = { + name: string; + columns: string[]; + unique?: boolean; +}; + export type DbFunctions = { jsonObjectFrom(expr: SelectQueryBuilderExpression): RawBuilder | null>; jsonArrayFrom(expr: SelectQueryBuilderExpression): RawBuilder[]>; @@ -44,7 +75,11 @@ export type DbFunctions = { const CONN_SYMBOL = Symbol.for("bknd:connection"); export abstract class Connection { + protected initialized = false; kysely: Kysely; + protected readonly supported = { + batching: false, + }; constructor( kysely: Kysely, @@ -55,6 +90,11 @@ export abstract class Connection { this[CONN_SYMBOL] = true; } + // @todo: consider moving constructor logic here, required by sqlocal + async init(): Promise { + this.initialized = true; + } + /** * This is a helper function to manage Connection classes * coming from different places @@ -65,17 +105,12 @@ export abstract class Connection { return conn[CONN_SYMBOL] === true; } - getIntrospector(): ConnectionIntrospector { - return this.kysely.introspection as ConnectionIntrospector; + getIntrospector(): BaseIntrospector { + return this.kysely.introspection as any; } - supportsBatching(): boolean { - return false; - } - - // @todo: add if only first field is used in index - supportsIndices(): boolean { - return false; + supports(feature: keyof typeof this.supported): boolean { + return this.supported[feature] ?? false; } async ping(): Promise { @@ -97,7 +132,7 @@ export abstract class Connection { [K in keyof Queries]: Awaited>; }> { // bypass if no client support - if (!this.supportsBatching()) { + if (!this.supports("batching")) { const data: any = []; for (const q of queries) { const result = await q.execute(); @@ -108,4 +143,19 @@ export abstract class Connection { return await this.batch(queries); } + + protected validateFieldSpecType(type: string): type is FieldSpec["type"] { + if (!FieldSpecTypes.includes(type as any)) { + throw new Error( + `Invalid field type "${type}". Allowed types are: ${FieldSpecTypes.join(", ")}`, + ); + } + return true; + } + + abstract getFieldSchema(spec: FieldSpec, strict?: boolean): SchemaResponse; + + async close(): Promise { + // no-op by default + } } diff --git a/app/src/data/connection/DummyConnection.ts b/app/src/data/connection/DummyConnection.ts index 451575d..d04d0af 100644 --- a/app/src/data/connection/DummyConnection.ts +++ b/app/src/data/connection/DummyConnection.ts @@ -1,7 +1,15 @@ -import { Connection } from "./Connection"; +import { Connection, type FieldSpec, type SchemaResponse } from "./Connection"; export class DummyConnection extends Connection { + protected override readonly supported = { + batching: true, + }; + constructor() { super(undefined as any); } + + override getFieldSchema(spec: FieldSpec, strict?: boolean): SchemaResponse { + throw new Error("Method not implemented."); + } } diff --git a/app/src/data/connection/SqliteConnection.ts b/app/src/data/connection/SqliteConnection.ts deleted file mode 100644 index 2572667..0000000 --- a/app/src/data/connection/SqliteConnection.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Kysely, KyselyPlugin } from "kysely"; -import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; -import { Connection, type DbFunctions } from "./Connection"; - -export class SqliteConnection extends Connection { - constructor(kysely: Kysely, fn: Partial = {}, plugins: KyselyPlugin[] = []) { - super( - kysely, - { - ...fn, - jsonArrayFrom, - jsonObjectFrom, - jsonBuildObject, - }, - plugins, - ); - } - - override supportsIndices(): boolean { - return true; - } -} diff --git a/app/src/data/connection/SqliteIntrospector.ts b/app/src/data/connection/SqliteIntrospector.ts deleted file mode 100644 index cf68816..0000000 --- a/app/src/data/connection/SqliteIntrospector.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { - DatabaseIntrospector, - DatabaseMetadata, - DatabaseMetadataOptions, - ExpressionBuilder, - Kysely, - SchemaMetadata, - TableMetadata, -} from "kysely"; -import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely"; -import type { ConnectionIntrospector, IndexMetadata } from "./Connection"; - -export type SqliteIntrospectorConfig = { - excludeTables?: string[]; -}; - -export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntrospector { - readonly #db: Kysely; - readonly _excludeTables: string[] = []; - - constructor(db: Kysely, config: SqliteIntrospectorConfig = {}) { - this.#db = db; - this._excludeTables = config.excludeTables ?? []; - } - - async getSchemas(): Promise { - // Sqlite doesn't support schemas. - return []; - } - - async getIndices(tbl_name?: string): Promise { - const indices = await this.#db - .selectFrom("sqlite_master") - .where("type", "=", "index") - .$if(!!tbl_name, (eb) => eb.where("tbl_name", "=", tbl_name)) - .select("name") - .$castTo<{ name: string }>() - .execute(); - - return Promise.all(indices.map(({ name }) => this.#getIndexMetadata(name))); - } - - async #getIndexMetadata(index: string): Promise { - const db = this.#db; - - // Get the SQL that was used to create the index. - const indexDefinition = await db - .selectFrom("sqlite_master") - .where("name", "=", index) - .select(["sql", "tbl_name", "type"]) - .$castTo<{ sql: string | undefined; tbl_name: string; type: string }>() - .executeTakeFirstOrThrow(); - - //console.log("--indexDefinition--", indexDefinition, index); - - // check unique by looking for the word "unique" in the sql - const isUnique = indexDefinition.sql?.match(/unique/i) != null; - - const columns = await db - .selectFrom( - sql<{ - seqno: number; - cid: number; - name: string; - }>`pragma_index_info(${index})`.as("index_info"), - ) - .select(["seqno", "cid", "name"]) - .orderBy("cid") - .execute(); - - return { - name: index, - table: indexDefinition.tbl_name, - isUnique: isUnique, - columns: columns.map((col) => ({ - name: col.name, - order: col.seqno, - })), - }; - } - - private excludeTables(tables: string[] = []) { - return (eb: ExpressionBuilder) => { - const and = tables.map((t) => eb("name", "!=", t)); - return eb.and(and); - }; - } - - async getTables( - options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, - ): Promise { - let query = this.#db - .selectFrom("sqlite_master") - .where("type", "in", ["table", "view"]) - .where("name", "not like", "sqlite_%") - .select("name") - .orderBy("name") - .$castTo<{ name: string }>(); - - if (!options.withInternalKyselyTables) { - query = query.where( - this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]), - ); - } - if (this._excludeTables.length > 0) { - query = query.where(this.excludeTables(this._excludeTables)); - } - - const tables = await query.execute(); - return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name))); - } - - async getMetadata(options?: DatabaseMetadataOptions): Promise { - return { - tables: await this.getTables(options), - }; - } - - async #getTableMetadata(table: string): Promise { - const db = this.#db; - - // Get the SQL that was used to create the table. - const tableDefinition = await db - .selectFrom("sqlite_master") - .where("name", "=", table) - .select(["sql", "type"]) - .$castTo<{ sql: string | undefined; type: string }>() - .executeTakeFirstOrThrow(); - - // Try to find the name of the column that has `autoincrement` 🤦 - const autoIncrementCol = tableDefinition.sql - ?.split(/[\(\),]/) - ?.find((it) => it.toLowerCase().includes("autoincrement")) - ?.trimStart() - ?.split(/\s+/)?.[0] - ?.replace(/["`]/g, ""); - - const columns = await db - .selectFrom( - sql<{ - name: string; - type: string; - notnull: 0 | 1; - dflt_value: any; - }>`pragma_table_info(${table})`.as("table_info"), - ) - .select(["name", "type", "notnull", "dflt_value"]) - .orderBy("cid") - .execute(); - - return { - name: table, - isView: tableDefinition.type === "view", - columns: columns.map((col) => ({ - name: col.name, - dataType: col.type, - isNullable: !col.notnull, - isAutoIncrementing: col.name === autoIncrementCol, - hasDefaultValue: col.dflt_value != null, - comment: undefined, - })), - }; - } -} diff --git a/app/src/data/connection/index.ts b/app/src/data/connection/index.ts new file mode 100644 index 0000000..2e745e0 --- /dev/null +++ b/app/src/data/connection/index.ts @@ -0,0 +1,14 @@ +export { BaseIntrospector } from "./BaseIntrospector"; +export { + Connection, + type FieldSpec, + type IndexSpec, + type DbFunctions, + type SchemaResponse, +} from "./Connection"; + +// sqlite +export { LibsqlConnection, type LibSqlCredentials } from "./sqlite/LibsqlConnection"; +export { SqliteConnection } from "./sqlite/SqliteConnection"; +export { SqliteIntrospector } from "./sqlite/SqliteIntrospector"; +export { SqliteLocalConnection } from "./sqlite/SqliteLocalConnection"; diff --git a/app/src/data/connection/LibsqlConnection.ts b/app/src/data/connection/sqlite/LibsqlConnection.ts similarity index 86% rename from app/src/data/connection/LibsqlConnection.ts rename to app/src/data/connection/sqlite/LibsqlConnection.ts index d341adc..895b6b0 100644 --- a/app/src/data/connection/LibsqlConnection.ts +++ b/app/src/data/connection/sqlite/LibsqlConnection.ts @@ -1,9 +1,9 @@ import { type Client, type Config, type InStatement, createClient } from "@libsql/client"; import { LibsqlDialect } from "@libsql/kysely-libsql"; +import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin"; +import { KyselyPluginRunner } from "data/plugins/KyselyPluginRunner"; import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely"; -import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin"; -import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner"; -import type { QB } from "./Connection"; +import type { QB } from "../Connection"; import { SqliteConnection } from "./SqliteConnection"; import { SqliteIntrospector } from "./SqliteIntrospector"; @@ -12,21 +12,26 @@ export type LibSqlCredentials = Config & { protocol?: (typeof LIBSQL_PROTOCOLS)[number]; }; +const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()]; + class CustomLibsqlDialect extends LibsqlDialect { override createIntrospector(db: Kysely): DatabaseIntrospector { return new SqliteIntrospector(db, { excludeTables: ["libsql_wasm_func_table"], + plugins, }); } } export class LibsqlConnection extends SqliteConnection { private client: Client; + protected override readonly supported = { + batching: true, + }; constructor(client: Client); constructor(credentials: LibSqlCredentials); constructor(clientOrCredentials: Client | LibSqlCredentials) { - const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()]; let client: Client; if (clientOrCredentials && "url" in clientOrCredentials) { let { url, authToken, protocol } = clientOrCredentials; @@ -51,14 +56,6 @@ export class LibsqlConnection extends SqliteConnection { this.client = client; } - override supportsBatching(): boolean { - return true; - } - - override supportsIndices(): boolean { - return true; - } - getClient(): Client { return this.client; } diff --git a/app/src/data/connection/sqlite/SqliteConnection.ts b/app/src/data/connection/sqlite/SqliteConnection.ts new file mode 100644 index 0000000..a63d49b --- /dev/null +++ b/app/src/data/connection/sqlite/SqliteConnection.ts @@ -0,0 +1,46 @@ +import type { ColumnDataType, ColumnDefinitionBuilder, Kysely, KyselyPlugin } from "kysely"; +import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; +import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "../Connection"; + +export class SqliteConnection extends Connection { + constructor(kysely: Kysely, fn: Partial = {}, plugins: KyselyPlugin[] = []) { + super( + kysely, + { + ...fn, + jsonArrayFrom, + jsonObjectFrom, + jsonBuildObject, + }, + plugins, + ); + } + + override getFieldSchema(spec: FieldSpec): SchemaResponse { + this.validateFieldSpecType(spec.type); + let type: ColumnDataType = spec.type; + + switch (spec.type) { + case "json": + type = "text"; + break; + } + + return [ + spec.name, + type, + (col: ColumnDefinitionBuilder) => { + if (spec.primary) { + return col.primaryKey().notNull().autoIncrement(); + } + if (spec.references) { + let relCol = col.references(spec.references); + if (spec.onDelete) relCol = relCol.onDelete(spec.onDelete); + if (spec.onUpdate) relCol = relCol.onUpdate(spec.onUpdate); + return relCol; + } + return spec.nullable ? col : col.notNull(); + }, + ] as const; + } +} diff --git a/app/src/data/connection/sqlite/SqliteIntrospector.ts b/app/src/data/connection/sqlite/SqliteIntrospector.ts new file mode 100644 index 0000000..f584050 --- /dev/null +++ b/app/src/data/connection/sqlite/SqliteIntrospector.ts @@ -0,0 +1,95 @@ +import { type SchemaMetadata, sql } from "kysely"; +import { BaseIntrospector } from "../BaseIntrospector"; + +export type SqliteSchemaSpec = { + name: string; + type: "table" | "view"; + sql: string; + columns: { + name: string; + type: string; + notnull: number; + dflt_value: any; + pk: number; + }[]; + indices: { + name: string; + origin: string; + partial: number; + sql: string; + columns: { name: string; seqno: number }[]; + }[]; +}; + +export class SqliteIntrospector extends BaseIntrospector { + async getSchemas(): Promise { + // Sqlite doesn't support schemas. + return []; + } + + async getSchemaSpec() { + const query = sql` + SELECT m.name, m.type, m.sql, + (SELECT json_group_array( + json_object( + 'name', p.name, + 'type', p.type, + 'notnull', p."notnull", + 'default', p.dflt_value, + 'primary_key', p.pk + )) FROM pragma_table_info(m.name) p) AS columns, + (SELECT json_group_array( + json_object( + 'name', i.name, + 'origin', i.origin, + 'partial', i.partial, + 'sql', im.sql, + 'columns', (SELECT json_group_array( + json_object( + 'name', ii.name, + 'seqno', ii.seqno + )) FROM pragma_index_info(i.name) ii) + )) FROM pragma_index_list(m.name) i + LEFT JOIN sqlite_master im ON im.name = i.name + AND im.type = 'index' + ) AS indices + FROM sqlite_master m + WHERE m.type IN ('table', 'view') + and m.name not like 'sqlite_%' + and m.name not in (${this.getExcludedTableNames().join(", ")}) + `; + + const tables = await this.executeWithPlugins(query); + + return tables.map((table) => ({ + name: table.name, + isView: table.type === "view", + columns: table.columns.map((col) => { + const autoIncrementCol = table.sql + ?.split(/[\(\),]/) + ?.find((it) => it.toLowerCase().includes("autoincrement")) + ?.trimStart() + ?.split(/\s+/)?.[0] + ?.replace(/["`]/g, ""); + + return { + name: col.name, + dataType: col.type, + isNullable: !col.notnull, + isAutoIncrementing: col.name === autoIncrementCol, + hasDefaultValue: col.dflt_value != null, + comment: undefined, + }; + }), + indices: table.indices.map((index) => ({ + name: index.name, + table: table.name, + isUnique: index.sql?.match(/unique/i) != null, + columns: index.columns.map((col) => ({ + name: col.name, + order: col.seqno, + })), + })), + })); + } +} diff --git a/app/src/data/connection/SqliteLocalConnection.ts b/app/src/data/connection/sqlite/SqliteLocalConnection.ts similarity index 62% rename from app/src/data/connection/SqliteLocalConnection.ts rename to app/src/data/connection/sqlite/SqliteLocalConnection.ts index 7c26428..a92577b 100644 --- a/app/src/data/connection/SqliteLocalConnection.ts +++ b/app/src/data/connection/sqlite/SqliteLocalConnection.ts @@ -1,30 +1,31 @@ -import { type DatabaseIntrospector, ParseJSONResultsPlugin, type SqliteDatabase } from "kysely"; -import { Kysely, SqliteDialect } from "kysely"; +import { + type DatabaseIntrospector, + Kysely, + ParseJSONResultsPlugin, + type SqliteDatabase, + SqliteDialect, +} from "kysely"; import { SqliteConnection } from "./SqliteConnection"; import { SqliteIntrospector } from "./SqliteIntrospector"; +const plugins = [new ParseJSONResultsPlugin()]; + class CustomSqliteDialect extends SqliteDialect { override createIntrospector(db: Kysely): DatabaseIntrospector { return new SqliteIntrospector(db, { excludeTables: ["test_table"], + plugins, }); } } export class SqliteLocalConnection extends SqliteConnection { constructor(private database: SqliteDatabase) { - const plugins = [new ParseJSONResultsPlugin()]; const kysely = new Kysely({ dialect: new CustomSqliteDialect({ database }), plugins, - //log: ["query"], }); - super(kysely); - this.plugins = plugins; - } - - override supportsIndices(): boolean { - return true; + super(kysely, {}, plugins); } } diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts index fcac3ef..ce6330a 100644 --- a/app/src/data/entities/Mutator.ts +++ b/app/src/data/entities/Mutator.ts @@ -167,7 +167,9 @@ export class Mutator< const res = await this.single(query); - await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data })); + await this.emgr.emit( + new Mutator.Events.MutatorInsertAfter({ entity, data: res.data, changed: validatedData }), + ); return res as any; } @@ -198,7 +200,12 @@ export class Mutator< const res = await this.single(query); await this.emgr.emit( - new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data }), + new Mutator.Events.MutatorUpdateAfter({ + entity, + entityId: id, + data: res.data, + changed: validatedData, + }), ); return res as any; diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 4734ddf..47657f8 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -14,6 +14,7 @@ import { WithBuilder, } from "../index"; import { JoinBuilder } from "./JoinBuilder"; +import { ensureInt } from "core/utils"; export type RepositoryQB = SelectQueryBuilder; @@ -225,8 +226,9 @@ export class Repository { +export class MutatorInsertAfter extends Event<{ + entity: Entity; + data: EntityData; + changed: EntityData; +}> { static override slug = "mutator-insert-after"; } export class MutatorUpdateBefore extends Event< @@ -48,6 +52,7 @@ export class MutatorUpdateAfter extends Event<{ entity: Entity; entityId: PrimaryFieldType; data: EntityData; + changed: EntityData; }> { static override slug = "mutator-update-after"; } diff --git a/app/src/data/fields/BooleanField.ts b/app/src/data/fields/BooleanField.ts index 6f73e6f..19d2978 100644 --- a/app/src/data/fields/BooleanField.ts +++ b/app/src/data/fields/BooleanField.ts @@ -32,9 +32,11 @@ export class BooleanField extends Field< } } - schema() { - // @todo: potentially use "integer" instead - return this.useSchemaHelper("boolean"); + override schema() { + return Object.freeze({ + ...super.schema()!, + type: "boolean", + }); } override getHtmlConfig() { diff --git a/app/src/data/fields/DateField.ts b/app/src/data/fields/DateField.ts index 5020376..c7ba901 100644 --- a/app/src/data/fields/DateField.ts +++ b/app/src/data/fields/DateField.ts @@ -32,8 +32,10 @@ export class DateField extends Field< } override schema() { - const type = this.config.type === "datetime" ? "datetime" : "date"; - return this.useSchemaHelper(type); + return Object.freeze({ + ...super.schema()!, + type: this.config.type === "datetime" ? "datetime" : "date", + }); } override getHtmlConfig() { diff --git a/app/src/data/fields/EnumField.ts b/app/src/data/fields/EnumField.ts index 79eee93..e8e8772 100644 --- a/app/src/data/fields/EnumField.ts +++ b/app/src/data/fields/EnumField.ts @@ -66,10 +66,6 @@ export class EnumField; -export type SchemaResponse = [string, ColumnDataType, ColumnBuilderCallback] | undefined; - export abstract class Field< Config extends BaseFieldConfig = BaseFieldConfig, Type = any, @@ -106,25 +104,18 @@ export abstract class Field< protected abstract getSchema(): TSchema; - protected useSchemaHelper( - type: ColumnDataType, - builder?: (col: ColumnDefinitionBuilder) => ColumnDefinitionBuilder, - ): SchemaResponse { - return [ - this.name, - type, - (col: ColumnDefinitionBuilder) => { - if (builder) return builder(col); - return col; - }, - ]; - } - /** * Used in SchemaManager.ts * @param em */ - abstract schema(em: EntityManager): SchemaResponse; + schema(): FieldSpec | undefined { + return Object.freeze({ + name: this.name, + type: "text", + nullable: true, + dflt: this.getDefault(), + }); + } hasDefault() { return this.config.default_value !== undefined; diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index 62a7677..b25df60 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -18,10 +18,6 @@ export class JsonField extends Field< }; } - schema() { - return this.useSchemaHelper("integer"); + override schema() { + return Object.freeze({ + ...super.schema()!, + type: "integer", + }); } override getValue(value: any, context?: TRenderContext): any { diff --git a/app/src/data/fields/PrimaryField.ts b/app/src/data/fields/PrimaryField.ts index 6245944..dd3463f 100644 --- a/app/src/data/fields/PrimaryField.ts +++ b/app/src/data/fields/PrimaryField.ts @@ -30,9 +30,12 @@ export class PrimaryField extends Field< return baseFieldConfigSchema; } - schema() { - return this.useSchemaHelper("integer", (col) => { - return col.primaryKey().notNull().autoIncrement(); + override schema() { + return Object.freeze({ + type: "integer", + name: this.name, + primary: true, + nullable: false, }); } diff --git a/app/src/data/fields/TextField.ts b/app/src/data/fields/TextField.ts index 7ebcea5..8c318ec 100644 --- a/app/src/data/fields/TextField.ts +++ b/app/src/data/fields/TextField.ts @@ -47,10 +47,6 @@ export class TextField extends Field< return textFieldConfigSchema; } - override schema() { - return this.useSchemaHelper("text"); - } - override getHtmlConfig() { if (this.config.html_config) { return this.config.html_config as any; diff --git a/app/src/data/fields/VirtualField.ts b/app/src/data/fields/VirtualField.ts index d02869b..c03db19 100644 --- a/app/src/data/fields/VirtualField.ts +++ b/app/src/data/fields/VirtualField.ts @@ -17,7 +17,7 @@ export class VirtualField extends Field { return virtualFieldConfigSchema; } - schema() { + override schema() { return undefined; } diff --git a/app/src/data/index.ts b/app/src/data/index.ts index 165436f..eb3e893 100644 --- a/app/src/data/index.ts +++ b/app/src/data/index.ts @@ -5,6 +5,7 @@ export * from "./entities"; export * from "./relations"; export * from "./schema/SchemaManager"; export * from "./prototype"; +export * from "./connection"; export { type RepoQuery, @@ -14,11 +15,6 @@ export { whereSchema, } from "./server/data-query-impl"; -export { Connection } from "./connection/Connection"; -export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection"; -export { SqliteConnection } from "./connection/SqliteConnection"; -export { SqliteLocalConnection } from "./connection/SqliteLocalConnection"; -export { SqliteIntrospector } from "./connection/SqliteIntrospector"; export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner"; export { constructEntity, constructRelation } from "./schema/constructor"; diff --git a/app/src/data/relations/RelationField.ts b/app/src/data/relations/RelationField.ts index b38cc1c..6868797 100644 --- a/app/src/data/relations/RelationField.ts +++ b/app/src/data/relations/RelationField.ts @@ -1,6 +1,6 @@ import { type Static, StringEnum, Type } from "core/utils"; import type { EntityManager } from "../entities"; -import { Field, type SchemaResponse, baseFieldConfigSchema } from "../fields"; +import { Field, baseFieldConfigSchema } from "../fields"; import type { EntityRelation } from "./EntityRelation"; import type { EntityRelationAnchor } from "./EntityRelationAnchor"; @@ -72,14 +72,12 @@ export class RelationField extends Field { return this.config.target_field!; } - override schema(): SchemaResponse { - return this.useSchemaHelper("integer", (col) => { - //col.references('person.id').onDelete('cascade').notNull() - // @todo: implement cascading? - - return col - .references(`${this.config.target}.${this.config.target_field}`) - .onDelete(this.config.on_delete ?? "set null"); + override schema() { + return Object.freeze({ + ...super.schema()!, + type: "integer", + references: `${this.config.target}.${this.config.target_field}`, + onDelete: this.config.on_delete ?? "set null", }); } diff --git a/app/src/data/schema/SchemaManager.ts b/app/src/data/schema/SchemaManager.ts index 58a3127..7ad1ba1 100644 --- a/app/src/data/schema/SchemaManager.ts +++ b/app/src/data/schema/SchemaManager.ts @@ -1,7 +1,7 @@ -import type { AlterTableColumnAlteringBuilder, CompiledQuery, TableMetadata } from "kysely"; -import type { IndexMetadata } from "../connection/Connection"; +import type { CompiledQuery, TableMetadata } from "kysely"; +import type { IndexMetadata, SchemaResponse } from "../connection/Connection"; import type { Entity, EntityManager } from "../entities"; -import { PrimaryField, type SchemaResponse } from "../fields"; +import { PrimaryField } from "../fields"; type IntrospectedTable = TableMetadata & { indices: IndexMetadata[]; @@ -49,10 +49,6 @@ export class SchemaManager { constructor(private readonly em: EntityManager) {} private getIntrospector() { - if (!this.em.connection.supportsIndices()) { - throw new Error("Indices are not supported by the current connection"); - } - return this.em.connection.getIntrospector(); } @@ -239,10 +235,9 @@ export class SchemaManager { for (const column of columns) { const field = this.em.entity(table).getField(column)!; - const fieldSchema = field.schema(this.em); - if (Array.isArray(fieldSchema) && fieldSchema.length === 3) { - schemas.push(fieldSchema); - //throw new Error(`Field "${field.name}" on entity "${table}" has no schema`); + const fieldSchema = field.schema(); + if (fieldSchema) { + schemas.push(this.em.connection.getFieldSchema(fieldSchema)); } } @@ -330,6 +325,7 @@ export class SchemaManager { if (local_updates === 0) continue; // iterate through built qbs + // @todo: run in batches for (const qb of qbs) { const { sql, parameters } = qb.compile(); statements.push({ sql, parameters }); diff --git a/app/src/media/MediaField.ts b/app/src/media/MediaField.ts index d865f59..66fd0b6 100644 --- a/app/src/media/MediaField.ts +++ b/app/src/media/MediaField.ts @@ -47,7 +47,7 @@ export class MediaField< return this.config.min_items; } - schema() { + override schema() { return undefined; } diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts index c5bfe78..37c651d 100644 --- a/app/src/media/storage/adapters/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/StorageS3Adapter.ts @@ -118,14 +118,20 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter { const res = await this.fetch(url, { method: "PUT", body, + headers: isFile(body) + ? { + // required for node environments + "Content-Length": String(body.size), + } + : {}, }); - if (res.ok) { - // "df20fcb574dba1446cf5ec997940492b" - return String(res.headers.get("etag")); + if (!res.ok) { + throw new Error(`Failed to upload object: ${res.status} ${res.statusText}`); } - return undefined; + // "df20fcb574dba1446cf5ec997940492b" + return String(res.headers.get("etag")); } private async headObject( diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 955b6c4..7c3b0ed 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -115,7 +115,7 @@ const configJsonSchema = Type.Union([ }), ), ]); -const __bknd = entity(TABLE_NAME, { +export const __bknd = entity(TABLE_NAME, { version: number().required(), type: enumm({ enum: ["config", "diff", "backup"] }).required(), json: jsonSchema({ schema: configJsonSchema }).required(), @@ -170,6 +170,8 @@ export class ModuleManager { } } + this.logger.log("booted with", this._booted_with); + this.createModules(initial); } @@ -218,7 +220,8 @@ export class ModuleManager { private repo() { return this.__em.repo(__bknd, { - silent: !debug_modules, + // to prevent exceptions when table doesn't exist + silent: true, }); } @@ -271,7 +274,7 @@ export class ModuleManager { }; } - private async fetch(): Promise { + private async fetch(): Promise { this.logger.context("fetch").log("fetching"); const startTime = performance.now(); @@ -285,7 +288,7 @@ export class ModuleManager { if (!result) { this.logger.log("error fetching").clear(); - throw BkndError.with("no config"); + return undefined; } this.logger @@ -305,6 +308,7 @@ export class ModuleManager { try { const state = await this.fetch(); + if (!state) throw new BkndError("save: no config found"); this.logger.log("fetched version", state.version); if (state.version !== version) { @@ -321,11 +325,11 @@ export class ModuleManager { json: configs, }); } else { - this.logger.log("version matches"); + this.logger.log("version matches", state.version); // clean configs because of Diff() function const diffs = diff(state.json, clone(configs)); - this.logger.log("checking diff", diffs); + this.logger.log("checking diff", [diffs.length]); if (diff.length > 0) { // store diff @@ -380,78 +384,6 @@ export class ModuleManager { return this; } - 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(); - if (state.version !== this.version()) { - // @todo: potentially drop provided config and use database version - throw new Error( - `Given version (${this.version()}) and fetched version (${state.version}) do not match.`, - ); - } - } catch (e: any) { - throw new Error(`Version is ${this.version()}, fetch failed: ${e.message}`); - } - - this.logger.log("now migrating"); - let version = this.version(); - let configs: any = this.configs(); - //console.log("migrating with", version, configs); - if (Object.keys(configs).length === 0) { - throw new Error("No config to migrate"); - } - - const [_version, _configs] = await migrate(version, configs, { - db: this.db, - }); - version = _version; - configs = _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 { this.logger.log("setting configs"); objectEach(configs, (config, key) => { @@ -469,66 +401,67 @@ export class ModuleManager { async build(opts?: { fetch?: boolean }) { this.logger.context("build").log("version", this.version()); - this.logger.log("booted with", this._booted_with); + await this.ctx().connection.init(); // if no config provided, try fetch from db if (this.version() === 0 || opts?.fetch === true) { - if (this.version() === 0) { - this.logger.context("no version").log("version is 0"); - } else { - this.logger.context("force fetch").log("force fetch"); + if (opts?.fetch) { + this.logger.log("force fetch"); } - try { - const result = await this.fetch(); + const result = await this.fetch(); + // if no version, and nothing found, go with initial + if (!result) { + this.logger.log("nothing in database, go initial"); + await this.setupInitial(); + } else { + this.logger.log("db has", result.version); // set version and config from fetched this._version = result.version; - if (this.version() !== CURRENT_VERSION) { - await this.syncConfigTable(); - } - if (this.options?.trustFetched === true) { this.logger.log("trusting fetched config (mark)"); mark(result.json); } - this.setConfigs(result.json); - } catch (e: any) { - this.logger.clear(); // fetch couldn't clear + // if version doesn't match, migrate before building + if (this.version() !== CURRENT_VERSION) { + this.logger.log("now migrating"); - this.logger.context("error handler").log("fetch failed", e.message); + await this.syncConfigTable(); - // we can safely build modules, since config version is up to date - // it's up to date because we use default configs (no fetch result) - this._version = CURRENT_VERSION; - await this.syncConfigTable(); - const state = await this.buildModules(); - if (!state.saved) { - await this.save(); + const version_before = this.version(); + const [_version, _configs] = await migrate(version_before, result.json, { + db: this.db, + }); + + this._version = _version; + this.ctx().flags.sync_required = true; + + this.logger.log("migrated to", _version); + $console.log("Migrated config from", version_before, "to", this.version()); + + this.createModules(_configs); + await this.buildModules(); + } else { + this.logger.log("version is current", this.version()); + this.createModules(result.json); + await this.buildModules(); } - - // run initial setup - await this.setupInitial(); - - this.logger.clear(); - return this; } - this.logger.clear(); - } - - // migrate to latest if needed - 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"); + if (this.version() !== CURRENT_VERSION) { + throw new Error( + `Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`, + ); + } + this.logger.log("current version is up to date", this.version()); await this.buildModules(); } this.logger.log("done"); + this.logger.clear(); return this; } @@ -589,6 +522,14 @@ export class ModuleManager { } protected async setupInitial() { + this.logger.context("initial").log("start"); + this._version = CURRENT_VERSION; + await this.syncConfigTable(); + const state = await this.buildModules(); + if (!state.saved) { + await this.save(); + } + const ctx = { ...this.ctx(), // disable events for initial setup @@ -601,6 +542,7 @@ export class ModuleManager { // run first boot event await this.options?.onFirstBoot?.(); + this.logger.clear(); } mutateConfigSafe( diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 8297a45..59911b1 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -1,6 +1,7 @@ import { _jsonp, transformObject } from "core/utils"; import { type Kysely, sql } from "kysely"; import { set } from "lodash-es"; +import type { InitialModuleConfigs } from "modules/ModuleManager"; export type MigrationContext = { db: Kysely; @@ -91,6 +92,17 @@ export const migrations: Migration[] = [ }; }, }, + { + // remove admin config + version: 9, + up: async (config) => { + const { admin, ...server } = config.server; + return { + ...config, + server, + }; + }, + }, ]; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index 5957467..f6a665a 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -5,9 +5,9 @@ import { config, isDebug } from "core"; import { addFlashMessage } from "core/server/flash"; import { html } from "hono/html"; import { Fragment } from "hono/jsx"; +import { css, Style } from "hono/css"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; -import type { AppTheme } from "modules/server/AppServer"; const htmlBkndContextReplace = ""; @@ -73,7 +73,6 @@ export class AdminController extends Controller { const obj = { user: c.get("auth")?.user, logout_route: this.withBasePath(authRoutes.logout), - color_scheme: configs.server.admin.color_scheme, }; const html = await this.getHtml(obj); if (!html) { @@ -183,14 +182,13 @@ export class AdminController extends Controller { assets.css = manifest["src/ui/main.tsx"].css[0] as any; } - const theme = configs.server.admin.color_scheme ?? "light"; const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico"; return ( {/* dnd complains otherwise */} {html``} - +
-
- - Initializing... - +