Merge pull request #109 from bknd-io/release/0.10

Release 0.10
This commit is contained in:
dswbx
2025-03-25 13:04:25 +01:00
committed by GitHub
207 changed files with 5943 additions and 3282 deletions

View File

@@ -42,7 +42,7 @@ Creating digital products always requires developing both the backend (the logic
* 🏃‍♂️ Multiple run modes * 🏃‍♂️ Multiple run modes
* standalone using the CLI * standalone using the CLI
* using a JavaScript runtime (Node, Bun, workerd) * 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 * 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components * ⚛️ React elements for auto-configured authentication and media components

View File

@@ -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 { App } from "../src";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterEach(afterAllCleanup);
describe("App tests", async () => { describe("App tests", async () => {
test("boots and pongs", async () => { test("boots and pongs", async () => {
@@ -12,4 +12,16 @@ describe("App tests", async () => {
//expect(await app.data?.em.ping()).toBeTrue(); //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());
});*/
}); });

View File

@@ -32,7 +32,7 @@ describe("MediaApi", () => {
host, host,
basepath, 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", () => { it("should have correct upload headers", () => {

View File

@@ -7,8 +7,8 @@ describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({ const strategy = new OAuthStrategy({
type: "oidc", type: "oidc",
client: { client: {
client_id: process.env.OAUTH_CLIENT_ID, client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET, client_secret: process.env.OAUTH_CLIENT_SECRET!,
}, },
name: "google", name: "google",
}); });
@@ -19,11 +19,6 @@ describe("OAuthStrategy", async () => {
const config = await strategy.getConfig(); const config = await strategy.getConfig();
console.log("config", JSON.stringify(config, null, 2)); console.log("config", JSON.stringify(config, null, 2));
const request = await strategy.request({
redirect_uri,
state,
});
const server = Bun.serve({ const server = Bun.serve({
fetch: async (req) => { fetch: async (req) => {
const url = new URL(req.url); const url = new URL(req.url);
@@ -39,6 +34,11 @@ describe("OAuthStrategy", async () => {
return new Response("Bun!"); return new Response("Bun!");
}, },
}); });
const request = await strategy.request({
redirect_uri,
state,
});
console.log("request", request); console.log("request", request);
await new Promise((resolve) => setTimeout(resolve, 100000)); await new Promise((resolve) => setTimeout(resolve, 100000));

View File

@@ -14,6 +14,7 @@ describe("env", () => {
expect(is_toggled(1)).toBe(true); expect(is_toggled(1)).toBe(true);
expect(is_toggled(0)).toBe(false); expect(is_toggled(0)).toBe(false);
expect(is_toggled("anything else")).toBe(false); expect(is_toggled("anything else")).toBe(false);
expect(is_toggled(undefined, true)).toBe(true);
}); });
test("env()", () => { test("env()", () => {

View File

@@ -27,7 +27,7 @@ describe("Relations", async () => {
const sql1 = schema const sql1 = schema
.createTable("posts") .createTable("posts")
.addColumn(...r1.schema()!) .addColumn(...em.connection.getFieldSchema(r1.schema())!)
.compile().sql; .compile().sql;
expect(sql1).toBe( expect(sql1).toBe(
@@ -43,7 +43,7 @@ describe("Relations", async () => {
const sql2 = schema const sql2 = schema
.createTable("posts") .createTable("posts")
.addColumn(...r2.schema()!) .addColumn(...em.connection.getFieldSchema(r2.schema())!)
.compile().sql; .compile().sql;
expect(sql2).toBe( expect(sql2).toBe(

View File

@@ -15,7 +15,7 @@ describe("SchemaManager tests", async () => {
const em = new EntityManager([entity], dummyConnection, [], [index]); const em = new EntityManager([entity], dummyConnection, [], [index]);
const schema = new SchemaManager(em); const schema = new SchemaManager(em);
const introspection = schema.getIntrospectionFromEntity(em.entities[0]); const introspection = schema.getIntrospectionFromEntity(em.entities[0]!);
expect(introspection).toEqual({ expect(introspection).toEqual({
name: "test", name: "test",
isView: false, isView: false,
@@ -109,7 +109,7 @@ describe("SchemaManager tests", async () => {
await schema.sync({ force: true, drop: true }); await schema.sync({ force: true, drop: true });
const diffAfter = await schema.getDiff(); const diffAfter = await schema.getDiff();
console.log("diffAfter", diffAfter); //console.log("diffAfter", diffAfter);
expect(diffAfter.length).toBe(0); expect(diffAfter.length).toBe(0);
await kysely.schema.dropTable(table).execute(); await kysely.schema.dropTable(table).execute();

View File

@@ -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,
},
],
},
]);
});
});

View File

@@ -1,23 +1,29 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Default, parse, stripMark } from "../../../../src/core/utils"; import { Default, stripMark } from "../../../../src/core/utils";
import { Field, type SchemaResponse, TextField, baseFieldConfigSchema } from "../../../../src/data"; import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
import { runBaseFieldTests, transformPersist } from "./inc"; import { runBaseFieldTests } from "./inc";
describe("[data] Field", async () => { describe("[data] Field", async () => {
class FieldSpec extends Field { class FieldSpec extends Field {
schema(): SchemaResponse {
return this.useSchemaHelper("text");
}
getSchema() { getSchema() {
return baseFieldConfigSchema; 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" }); runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
test("default config", async () => { test("default config", async () => {
const config = Default(baseFieldConfigSchema, {}); 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 () => { test("transformPersist (specific)", async () => {

View File

@@ -10,7 +10,12 @@ describe("[data] PrimaryField", async () => {
test("schema", () => { test("schema", () => {
expect(field.name).toBe("primary"); 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 () => { test("hasDefault", async () => {

View File

@@ -34,11 +34,14 @@ export function runBaseFieldTests(
test("schema", () => { test("schema", () => {
expect(noConfigField.name).toBe("no_config"); expect(noConfigField.name).toBe("no_config");
expect(noConfigField.schema(null as any)).toEqual([
"no_config", const { type, name, nullable, dflt } = noConfigField.schema()!;
config.schemaType, expect({ type, name, nullable, dflt }).toEqual({
expect.any(Function), type: config.schemaType as any,
]); name: "no_config",
nullable: true, // always true
dflt: undefined, // never using default value
});
}); });
test("hasDefault", async () => { test("hasDefault", async () => {

View File

@@ -4,6 +4,9 @@ import Database from "libsql";
import { format as sqlFormat } from "sql-formatter"; import { format as sqlFormat } from "sql-formatter";
import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data"; import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data";
import type { em as protoEm } from "../src/data/prototype"; 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): { export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase; dummyDb: SqliteDatabase;
@@ -71,3 +74,46 @@ export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): En
export const assetsPath = `${import.meta.dir}/_assets`; export const assetsPath = `${import.meta.dir}/_assets`;
export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; 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;
};
}

View File

@@ -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 { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth"; import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares"; import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; 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); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -64,6 +67,7 @@ const configs = {
function createAuthApp() { function createAuthApp() {
const app = createApp({ const app = createApp({
connection: dummyConnection,
initialConfig: { initialConfig: {
auth: configs.auth, auth: configs.auth,
}, },

View File

@@ -39,8 +39,8 @@ function makeName(ext: string) {
return randomString(10) + "." + ext; return randomString(10) + "." + ext;
} }
/*beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);*/ afterAll(enableConsoleLog);
describe("MediaController", () => { describe("MediaController", () => {
test.only("accepts direct", async () => { test.only("accepts direct", async () => {
@@ -56,9 +56,9 @@ describe("MediaController", () => {
console.log(result); console.log(result);
expect(result.name).toBe(name); expect(result.name).toBe(name);
/*const destFile = Bun.file(assetsTmpPath + "/" + name); const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true); expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();*/ await destFile.delete();
}); });
test("accepts form data", async () => { test("accepts form data", async () => {

View File

@@ -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 { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media"; import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv"; import { config } from "dotenv";
//import { enableFetchLogging } from "../../helper";
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` }); 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 } = const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!; 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; const ALL_TESTS = !!process.env.ALL_TESTS;
console.log("ALL_TESTS?", 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; if (ALL_TESTS) return;
const versions = [ const versions = [
@@ -66,7 +77,7 @@ describe.skipIf(true)("StorageS3Adapter", async () => {
test.skipIf(disabled("putObject"))("puts an object", async () => { test.skipIf(disabled("putObject"))("puts an object", async () => {
objects = (await adapter.listObjects()).length; 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 () => { test.skipIf(disabled("listObjects"))("lists objects", async () => {

View File

@@ -4,6 +4,7 @@ import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "../../src/data"; import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
// @ts-ignore
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
describe("AppAuth", () => { describe("AppAuth", () => {
@@ -22,7 +23,7 @@ describe("AppAuth", () => {
const config = auth.toJSON(); const config = auth.toJSON();
expect(config.jwt).toBeUndefined(); expect(config.jwt).toBeUndefined();
expect(config.strategies.password.config).toBeUndefined(); expect(config.strategies?.password?.config).toBeUndefined();
}); });
test("enabling auth: generate secret", async () => { test("enabling auth: generate secret", async () => {
@@ -42,6 +43,7 @@ describe("AppAuth", () => {
const auth = new AppAuth( const auth = new AppAuth(
{ {
enabled: true, enabled: true,
// @ts-ignore
jwt: { jwt: {
secret: "123456", secret: "123456",
}, },
@@ -75,7 +77,7 @@ describe("AppAuth", () => {
const { data: users } = await ctx.em.repository("users").findMany(); const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1); 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 authField = make(name, _authFieldProto as any);
const field = users.field(name)!; const field = users.field(name)!;
for (const prop of props) { for (const prop of props) {
expect(field.config[prop]).toBe(authField.config[prop]); expect(field.config[prop]).toEqual(authField.config[prop]);
} }
} }
}); });

View File

@@ -43,7 +43,7 @@ describe("ModuleManager", async () => {
}).toJSON(), }).toJSON(),
}, },
}, },
}); }) as any;
//const { version, ...json } = mm.toJSON() as any; //const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -73,7 +73,7 @@ describe("ModuleManager", async () => {
}).toJSON(), }).toJSON(),
}, },
}, },
}; } as any;
//const { version, ...json } = mm.toJSON() as any; //const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -90,23 +90,20 @@ describe("ModuleManager", async () => {
await mm2.build(); await mm2.build();
expect(stripMark(json)).toEqual(stripMark(mm2.configs())); expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities.test).toBeDefined(); expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities.test.fields.content).toBeDefined(); expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
expect(mm2.get("data").toJSON().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 () => { test("s4: config given, table exists, version outdated, migrate", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
const version = mm.version();
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely; const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, { const mm2 = new ModuleManager(c2.dummyConnection);
initial: { version: version - 1, ...json },
});
await mm2.syncConfigTable(); await mm2.syncConfigTable();
await db await db
@@ -171,7 +168,7 @@ describe("ModuleManager", async () => {
expect(mm2.configs().data.basepath).toBe("/api/data2"); expect(mm2.configs().data.basepath).toBe("/api/data2");
}); });
test("blank app, modify config", async () => { /*test("blank app, modify config", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection); const mm = new ModuleManager(dummyConnection);
@@ -194,7 +191,7 @@ describe("ModuleManager", async () => {
}, },
}, },
}); });
}); });*/
test("partial config given", async () => { test("partial config given", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -212,13 +209,15 @@ describe("ModuleManager", async () => {
expect(mm.version()).toBe(CURRENT_VERSION); expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true); expect(mm.built()).toBe(true);
expect(mm.configs().auth.enabled).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 () => { test("partial config given, but db version exists", async () => {
const c = getDummyConnection(); const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection); const mm = new ModuleManager(c.dummyConnection);
await mm.build(); await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs(); const json = mm.configs();
const c2 = getDummyConnection(); const c2 = getDummyConnection();
@@ -265,8 +264,8 @@ describe("ModuleManager", async () => {
override async build() { override async build() {
//console.log("building FailingModule", this.config); //console.log("building FailingModule", this.config);
if (this.config.value < 0) { if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive"); throw new Error("value must be positive, given: " + this.config.value);
} }
this.setBuilt(); this.setBuilt();
} }
@@ -332,6 +331,7 @@ describe("ModuleManager", async () => {
// @ts-ignore // @ts-ignore
const f = mm.mutateConfigSafe("failing"); const f = mm.mutateConfigSafe("failing");
// @ts-ignore
expect(f.set({ value: 2 })).resolves.toBeDefined(); expect(f.set({ value: 2 })).resolves.toBeDefined();
expect(mockOnUpdated).toHaveBeenCalled(); expect(mockOnUpdated).toHaveBeenCalled();
}); });

View File

@@ -1,39 +1,44 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src"; import { type InitialModuleConfigs, createApp } from "../../../src";
import type { Kysely } from "kysely"; import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper"; import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json"; 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 // app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs) { async function createVersionedApp(config: InitialModuleConfigs | any) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version"); if (!("version" in config)) throw new Error("config must have a version");
const { version, ...rest } = config; const { version, ...rest } = config;
const app = createApp({ connection: dummyConnection }); const db = dummyConnection.kysely as Kysely<any>;
await app.build(); 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<any>; await db
const current = await qb .insertInto("__bknd")
.selectFrom("__bknd") .values({
.selectAll() version,
.where("type", "=", "config") type: "config",
.executeTakeFirst(); created_at: new Date().toISOString(),
json: JSON.stringify(rest),
await qb })
.updateTable("__bknd")
.set("json", JSON.stringify(rest))
.set("version", 7)
.where("id", "=", current!.id)
.execute(); .execute();
const app2 = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
}); });
await app2.build(); await app.build();
return app2; return app;
} }
describe("Migrations", () => { describe("Migrations", () => {
@@ -46,8 +51,8 @@ describe("Migrations", () => {
const app = await createVersionedApp(v7); const app = await createVersionedApp(v7);
expect(app.version()).toBe(8); expect(app.version()).toBeGreaterThan(7);
expect(app.toJSON(true).auth.strategies.password.enabled).toBe(true); expect(app.toJSON(true).auth.strategies?.password?.enabled).toBe(true);
const req = await app.server.request("/api/auth/password/register", { const req = await app.server.request("/api/auth/password/register", {
method: "POST", method: "POST",
@@ -60,7 +65,17 @@ describe("Migrations", () => {
}), }),
}); });
expect(req.ok).toBe(true); 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"); 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();
});
}); });

View File

@@ -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": {}
}
}

View File

@@ -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": "<h1>Posts</h1>\n<ul>\n {% for post in fetching.output %}\n <li>{{ post.title }}</li>\n {% endfor %}\n</ul>"
}
}
},
"connections": {
"5cce66b5-57c6-4541-88ac-b298794c6c52": {
"source": "fetching",
"target": "render",
"config": {
"condition": {
"type": "success"
}
}
}
},
"start_task": "fetching",
"responding_task": "render"
}
}
}
}

View File

@@ -1,13 +0,0 @@
//import type { BkndConfig } from "./src";
export default {
app: {
connection: {
type: "libsql",
config: {
//url: "http://localhost:8080"
url: ":memory:"
}
}
}
};

View File

@@ -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<Omit<esbuild.BuildOptions, "plugins">> & { 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<string, BuildFn> = {
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<string, object> = {};
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<esbuild.BuildOptions> = {}): 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()]);
}

View File

@@ -46,17 +46,28 @@ if (types && !watch) {
buildTypes(); 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 * Building backend and general API
*/ */
async function buildApi() { async function buildApi() {
banner("Building API");
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
watch, watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist", outDir: "dist",
external: ["bun:test", "@libsql/client"], external: [...external],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm"], format: ["esm"],
@@ -85,7 +96,7 @@ async function buildUi() {
sourcemap, sourcemap,
watch, watch,
external: [ external: [
"bun:test", ...external,
"react", "react",
"react-dom", "react-dom",
"react/jsx-runtime", "react/jsx-runtime",
@@ -109,6 +120,7 @@ async function buildUi() {
}, },
} satisfies tsup.Options; } satisfies tsup.Options;
banner("Building UI");
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], 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({ await tsup.build({
...base, ...base,
entry: ["src/ui/client/index.ts"], entry: ["src/ui/client/index.ts"],
@@ -136,6 +149,7 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client" * - ui/client is external, and after built replaced with "bknd/client"
*/ */
async function buildUiElements() { async function buildUiElements() {
banner("Building UI Elements");
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
@@ -205,6 +219,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
} }
async function buildAdapters() { async function buildAdapters() {
banner("Building Adapters");
// base adapter handles // base adapter handles
await tsup.build({ await tsup.build({
...baseConfig(""), ...baseConfig(""),
@@ -213,7 +228,7 @@ async function buildAdapters() {
}); });
// specific adatpers // specific adatpers
await tsup.build(baseConfig("remix")); await tsup.build(baseConfig("react-router"));
await tsup.build(baseConfig("bun")); await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro")); await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws")); await tsup.build(baseConfig("aws"));

View File

@@ -3,8 +3,8 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.9.1", "version": "0.10.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -19,7 +19,7 @@
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"build": "NODE_ENV=production bun run build.ts --minify --types", "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: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", "build:static": "vite build",
"watch": "bun run build.ts --types --watch", "watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit", "types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -32,81 +32,84 @@
}, },
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^2.0.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1", "@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@mantine/core": "^7.13.4", "@mantine/core": "^7.17.1",
"@sinclair/typebox": "^0.32.34", "@mantine/hooks": "^7.17.1",
"@tanstack/react-form": "0.19.2", "@sinclair/typebox": "^0.34.30",
"@uiw/react-codemirror": "^4.23.6", "@tanstack/react-form": "^1.0.5",
"@xyflow/react": "^12.3.2", "@uiw/react-codemirror": "^4.23.10",
"aws4fetch": "^1.0.18", "@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^5.0.8",
"hono": "^4.6.12", "hono": "^4.7.4",
"json-schema-form-react": "^0.0.2", "json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7", "json-schema-library": "^10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.4", "kysely": "^0.27.6",
"liquidjs": "^10.15.0", "liquidjs": "^10.21.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"radix-ui": "^1.1.2", "radix-ui": "^1.1.3",
"swr": "^2.2.5" "swr": "^2.3.3"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@mantine/modals": "^7.13.4", "@hono/typebox-validator": "^0.3.2",
"@mantine/notifications": "^7.13.4", "@hono/vite-dev-server": "^0.19.0",
"@hono/typebox-validator": "^0.2.6", "@hookform/resolvers": "^4.1.3",
"@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@rjsf/core": "5.22.2", "@rjsf/core": "5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@types/node": "^22.10.0", "@tailwindcss/postcss": "^4.0.12",
"@types/react": "^18.3.12", "@tailwindcss/vite": "^4.0.12",
"@types/react-dom": "^18.3.1", "@types/node": "^22.13.10",
"@vitejs/plugin-react": "^4.3.3", "@types/react": "^19.0.10",
"autoprefixer": "^10.4.20", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"esbuild-postcss": "^0.0.4", "jotai": "^2.12.2",
"jotai": "^2.10.1",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"postcss": "^8.4.47", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "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-icons": "5.2.1",
"react-json-view-lite": "^2.0.1", "react-json-view-lite": "^2.4.1",
"sql-formatter": "^15.4.9", "sql-formatter": "^15.4.11",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^3.0.2",
"tailwindcss": "^3.4.14", "tailwindcss": "^4.0.12",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.10", "tsc-alias": "^1.8.11",
"tsup": "^8.3.5", "tsup": "^8.4.0",
"vite": "^5.4.10", "vite": "^6.2.1",
"vite-plugin-static-copy": "^2.0.0", "vite-tsconfig-paths": "^5.1.4",
"vite-tsconfig-paths": "^5.0.1", "wouter": "^3.6.0"
"wouter": "^3.3.5"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.13.7" "@hono/node-server": "^1.13.8"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18", "react": ">=19",
"react-dom": ">=18" "react-dom": ">=19"
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
@@ -171,10 +174,10 @@
"import": "./dist/adapter/nextjs/index.js", "import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs" "require": "./dist/adapter/nextjs/index.cjs"
}, },
"./adapter/remix": { "./adapter/react-router": {
"types": "./dist/types/adapter/remix/index.d.ts", "types": "./dist/types/adapter/react-router/index.d.ts",
"import": "./dist/adapter/remix/index.js", "import": "./dist/adapter/react-router/index.js",
"require": "./dist/adapter/remix/index.cjs" "require": "./dist/adapter/react-router/index.cjs"
}, },
"./adapter/bun": { "./adapter/bun": {
"types": "./dist/types/adapter/bun/index.d.ts", "types": "./dist/types/adapter/bun/index.d.ts",
@@ -224,8 +227,9 @@
"cloudflare", "cloudflare",
"nextjs", "nextjs",
"remix", "remix",
"react-router",
"astro", "astro",
"bun", "bun",
"node" "node"
] ]
} }

View File

@@ -1,9 +1,6 @@
export default { export default {
plugins: { plugins: {
"postcss-import": {}, "@tailwindcss/postcss": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
"postcss-preset-mantine": {}, "postcss-preset-mantine": {},
"postcss-simple-vars": { "postcss-simple-vars": {
variables: { variables: {

View File

@@ -18,6 +18,10 @@ class CustomD1Dialect extends D1Dialect {
} }
export class D1Connection extends SqliteConnection { export class D1Connection extends SqliteConnection {
protected override readonly supported = {
batching: true,
};
constructor(private config: D1ConnectionConfig) { constructor(private config: D1ConnectionConfig) {
const plugins = [new ParseJSONResultsPlugin()]; const plugins = [new ParseJSONResultsPlugin()];
@@ -28,14 +32,6 @@ export class D1Connection extends SqliteConnection {
super(kysely, {}, plugins); super(kysely, {}, plugins);
} }
override supportsBatching(): boolean {
return true;
}
override supportsIndices(): boolean {
return true;
}
protected override async batch<Queries extends QB[]>( protected override async batch<Queries extends QB[]>(
queries: [...Queries], queries: [...Queries],
): Promise<{ ): Promise<{

View File

@@ -1,15 +1,22 @@
import type { App } from "bknd"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { getRuntimeKey, isNode } from "core/utils"; import { isNode } from "core/utils";
export type NextjsBkndConfig = FrameworkBkndConfig & { export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanRequest?: { searchParams?: string[] }; cleanRequest?: { searchParams?: string[] };
}; };
type NextjsContext = {
env: Record<string, string | undefined>;
};
let app: App; let app: App;
let building: boolean = false; let building: boolean = false;
export async function getApp(config: NextjsBkndConfig) { export async function getApp<Args extends NextjsContext = NextjsContext>(
config: NextjsBkndConfig,
args?: Args,
) {
if (building) { if (building) {
while (building) { while (building) {
await new Promise((resolve) => setTimeout(resolve, 5)); await new Promise((resolve) => setTimeout(resolve, 5));
@@ -19,7 +26,7 @@ export async function getApp(config: NextjsBkndConfig) {
building = true; building = true;
if (!app) { if (!app) {
app = await createFrameworkApp(config); app = await createFrameworkApp(config, args);
await app.build(); await app.build();
} }
building = false; building = false;
@@ -52,7 +59,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) { export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) {
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await getApp(config); app = await getApp(config, { env: process.env ?? {} });
} }
const request = getCleanRequest(req, cleanRequest); const request = getCleanRequest(req, cleanRequest);
return app.fetch(request); return app.fetch(request);

View File

@@ -0,0 +1 @@
export * from "./react-router.adapter";

View File

@@ -1,18 +1,17 @@
import type { App } from "bknd"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>; type ReactRouterContext = {
type RemixContext = {
request: Request; request: Request;
}; };
export type ReactRouterBkndConfig<Args = ReactRouterContext> = FrameworkBkndConfig<Args>;
let app: App; let app: App;
let building: boolean = false; let building: boolean = false;
export async function getApp<Args extends RemixContext = RemixContext>( export async function getApp<Args extends ReactRouterContext = ReactRouterContext>(
config: RemixBkndConfig<Args>, config: ReactRouterBkndConfig<Args>,
args?: Args args?: Args,
) { ) {
if (building) { if (building) {
while (building) { while (building) {
@@ -30,8 +29,8 @@ export async function getApp<Args extends RemixContext = RemixContext>(
return app; return app;
} }
export function serve<Args extends RemixContext = RemixContext>( export function serve<Args extends ReactRouterContext = ReactRouterContext>(
config: RemixBkndConfig<Args> = {}, config: ReactRouterBkndConfig<Args> = {},
) { ) {
return async (args: Args) => { return async (args: Args) => {
app = await getApp(config, args); app = await getApp(config, args);

View File

@@ -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 (
<Suspense>
<Admin withProvider={{ user: auth.user }} {...props} />
</Suspense>
);
};
}

View File

@@ -1,2 +0,0 @@
export * from "./remix.adapter";
export * from "./AdminPage";

View File

@@ -9,6 +9,7 @@ import { env } from "core";
import color from "picocolors"; import color from "picocolors";
import { overridePackageJson, updateBkndPackages } from "./npm"; import { overridePackageJson, updateBkndPackages } from "./npm";
import { type Template, templates } from "./templates"; import { type Template, templates } from "./templates";
import { createScoped, flush } from "cli/utils/telemetry";
const config = { const config = {
types: { types: {
@@ -23,7 +24,7 @@ const config = {
}, },
framework: { framework: {
nextjs: "Next.js", nextjs: "Next.js",
remix: "Remix", "react-router": "React Router",
astro: "Astro", astro: "Astro",
}, },
} as const; } as const;
@@ -48,8 +49,16 @@ function errorOutro() {
process.exit(1); process.exit(1);
} }
async function onExit() {
await flush();
}
async function action(options: { template?: string; dir?: string; integration?: string }) { async function action(options: { template?: string; dir?: string; integration?: string }) {
console.log(""); console.log("");
const $t = createScoped("create");
$t.capture("start", {
options,
});
const downloadOpts = { const downloadOpts = {
dir: options.dir || "./", dir: options.dir || "./",
@@ -68,6 +77,7 @@ async function action(options: { template?: string; dir?: string; integration?:
})(), })(),
); );
$t.properties.at = "dir";
if (!options.dir) { if (!options.dir) {
const dir = await $p.text({ const dir = await $p.text({
message: "Where to create your project?", message: "Where to create your project?",
@@ -75,24 +85,29 @@ async function action(options: { template?: string; dir?: string; integration?:
initialValue: downloadOpts.dir, initialValue: downloadOpts.dir,
}); });
if ($p.isCancel(dir)) { if ($p.isCancel(dir)) {
await onExit();
process.exit(1); process.exit(1);
} }
downloadOpts.dir = dir || "./"; downloadOpts.dir = dir || "./";
} }
$t.properties.at = "dir";
if (fs.existsSync(downloadOpts.dir)) { if (fs.existsSync(downloadOpts.dir)) {
const clean = await $p.confirm({ const clean = await $p.confirm({
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`, message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
initialValue: false, initialValue: false,
}); });
if ($p.isCancel(clean)) { if ($p.isCancel(clean)) {
await onExit();
process.exit(1); process.exit(1);
} }
downloadOpts.clean = clean; downloadOpts.clean = clean;
$t.properties.clean = clean;
} }
// don't track name for privacy
let name = downloadOpts.dir.includes("/") let name = downloadOpts.dir.includes("/")
? downloadOpts.dir.split("/").pop() ? downloadOpts.dir.split("/").pop()
: downloadOpts.dir.replace(/[./]/g, ""); : downloadOpts.dir.replace(/[./]/g, "");
@@ -100,13 +115,17 @@ async function action(options: { template?: string; dir?: string; integration?:
if (!name || name.length === 0) name = "bknd"; if (!name || name.length === 0) name = "bknd";
let template: Template | undefined; let template: Template | undefined;
if (options.template) { if (options.template) {
$t.properties.at = "template";
template = templates.find((t) => t.key === options.template) as Template; template = templates.find((t) => t.key === options.template) as Template;
if (!template) { if (!template) {
await onExit();
$p.log.error(`Template ${color.cyan(options.template)} not found`); $p.log.error(`Template ${color.cyan(options.template)} not found`);
process.exit(1); process.exit(1);
} }
} else { } else {
$t.properties.at = "integration";
let integration: string | undefined = options.integration; let integration: string | undefined = options.integration;
if (!integration) { if (!integration) {
await $p.stream.info( await $p.stream.info(
@@ -128,8 +147,10 @@ async function action(options: { template?: string; dir?: string; integration?:
}); });
if ($p.isCancel(type)) { if ($p.isCancel(type)) {
await onExit();
process.exit(1); process.exit(1);
} }
$t.properties.type = type;
const _integration = await $p.select({ const _integration = await $p.select({
message: `Which ${color.cyan(config.types[type])} do you want to continue with?`, 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, })) as any,
}); });
if ($p.isCancel(_integration)) { if ($p.isCancel(_integration)) {
await onExit();
process.exit(1); process.exit(1);
} }
integration = String(_integration); integration = String(_integration);
$t.properties.integration = integration;
} }
if (!integration) { if (!integration) {
await onExit();
$p.log.error("No integration selected"); $p.log.error("No integration selected");
process.exit(1); process.exit(1);
} }
@@ -152,15 +176,18 @@ async function action(options: { template?: string; dir?: string; integration?:
const choices = templates.filter((t) => t.integration === integration); const choices = templates.filter((t) => t.integration === integration);
if (choices.length === 0) { if (choices.length === 0) {
await onExit();
$p.log.error(`No templates found for "${color.cyan(String(integration))}"`); $p.log.error(`No templates found for "${color.cyan(String(integration))}"`);
process.exit(1); process.exit(1);
} else if (choices.length > 1) { } else if (choices.length > 1) {
$t.properties.at = "template";
const selected_template = await $p.select({ const selected_template = await $p.select({
message: "Pick a template", message: "Pick a template",
options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description })), options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description })),
}); });
if ($p.isCancel(selected_template)) { if ($p.isCancel(selected_template)) {
await onExit();
process.exit(1); process.exit(1);
} }
@@ -170,10 +197,12 @@ async function action(options: { template?: string; dir?: string; integration?:
} }
} }
if (!template) { if (!template) {
await onExit();
$p.log.error("No template selected"); $p.log.error("No template selected");
process.exit(1); process.exit(1);
} }
$t.properties.template = template.key;
const ctx = { template, dir: downloadOpts.dir, name }; 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)); $p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(given));
}, },
}); });
$t.properties.ref = ref;
$t.capture("used");
const prefix = const prefix =
template.ref === true template.ref === true
@@ -191,7 +222,6 @@ async function action(options: { template?: string; dir?: string; integration?:
: ""; : "";
const url = `${template.path}${prefix}`; const url = `${template.path}${prefix}`;
//console.log("url", url);
const s = $p.spinner(); const s = $p.spinner();
await s.start("Downloading template..."); await s.start("Downloading template...");
try { try {
@@ -234,8 +264,10 @@ async function action(options: { template?: string; dir?: string; integration?:
}); });
if ($p.isCancel(install)) { if ($p.isCancel(install)) {
await onExit();
process.exit(1); process.exit(1);
} else if (install) { } else if (install) {
$t.properties.install = true;
const install_cmd = template.scripts?.install || "npm install"; const install_cmd = template.scripts?.install || "npm install";
const s = $p.spinner(); const s = $p.spinner();
@@ -259,6 +291,7 @@ async function action(options: { template?: string; dir?: string; integration?:
await template.postinstall(ctx); await template.postinstall(ctx);
} }
} else { } else {
$t.properties.install = false;
await $p.stream.warn( await $p.stream.warn(
(async function* () { (async function* () {
yield* typewriter( 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.")); $p.outro(color.green("Setup complete."));
} }

View File

@@ -1,6 +1,4 @@
import { cloudflare } from "./cloudflare"; import { cloudflare } from "./cloudflare";
import { nextjs } from "./nextjs";
import { remix } from "./remix";
export type TemplateSetupCtx = { export type TemplateSetupCtx = {
template: Template; template: Template;
@@ -13,7 +11,7 @@ export type Integration =
| "bun" | "bun"
| "cloudflare" | "cloudflare"
| "nextjs" | "nextjs"
| "remix" | "react-router"
| "astro" | "astro"
| "aws" | "aws"
| "custom"; | "custom";
@@ -43,8 +41,6 @@ export type Template = {
export const templates: Template[] = [ export const templates: Template[] = [
cloudflare, cloudflare,
nextjs,
remix,
{ {
key: "node", key: "node",
title: "Node.js Basic", title: "Node.js Basic",
@@ -61,6 +57,14 @@ export const templates: Template[] = [
path: "gh:bknd-io/bknd/examples/bun", path: "gh:bknd-io/bknd/examples/bun",
ref: true, 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", key: "astro",
title: "Astro Basic", title: "Astro Basic",
@@ -69,6 +73,14 @@ export const templates: Template[] = [
path: "gh:bknd-io/bknd/examples/astro", path: "gh:bknd-io/bknd/examples/astro",
ref: true, 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", key: "aws",
title: "AWS Lambda Basic", title: "AWS Lambda Basic",

View File

@@ -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;

View File

@@ -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;

View File

@@ -22,6 +22,7 @@ const isBun = typeof Bun !== "undefined";
export const run: CliCommand = (program) => { export const run: CliCommand = (program) => {
program program
.command("run") .command("run")
.description("run an instance")
.addOption( .addOption(
new Option("-p, --port <port>", "port to run on") new Option("-p, --port <port>", "port to run on")
.env("PORT") .env("PORT")

View File

@@ -4,21 +4,36 @@ import { Command } from "commander";
import color from "picocolors"; import color from "picocolors";
import * as commands from "./commands"; import * as commands from "./commands";
import { getVersion } from "./utils/sys"; import { getVersion } from "./utils/sys";
import { capture, flush, init } from "cli/utils/telemetry";
const program = new Command(); const program = new Command();
export async function main() { export async function main() {
await init();
capture("start");
const version = await getVersion(); const version = await getVersion();
program program
.name("bknd") .name("bknd")
.description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`))) .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 // register commands
for (const command of Object.values(commands)) { for (const command of Object.values(commands)) {
command(program); command(program);
} }
program.parse(); await program.parseAsync();
} }
main().then(null).catch(console.error); main()
.then(null)
.catch(async (e) => {
await flush();
console.error(e);
});

View File

@@ -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<boolean> {
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);
}
}

View File

@@ -1,7 +1,7 @@
export type Env = {}; export type Env = {};
export const is_toggled = (given: unknown): boolean => { export const is_toggled = (given: unknown, fallback?: boolean): boolean => {
return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(given); return typeof given === "string" ? [1, "1", "true"].includes(given) : Boolean(given || fallback);
}; };
export function isDebug(): boolean { export function isDebug(): boolean {
@@ -34,6 +34,16 @@ const envs = {
return typeof v === "string" ? v : undefined; 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: { // module manager debug: {
modules_debug: { modules_debug: {
key: "BKND_MODULES_DEBUG", key: "BKND_MODULES_DEBUG",

View File

@@ -30,7 +30,7 @@ export class DebugLogger {
const now = performance.now(); const now = performance.now();
const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last)); 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 = const context =
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
console.log(indents, context, time, ...args); console.log(indents, context, time, ...args);

View File

@@ -3,3 +3,11 @@ export function clampNumber(value: number, min: number, max: number): number {
const upper = Math.max(min, max); const upper = Math.max(min, max);
return Math.max(lower, Math.min(value, upper)); 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);
}

View File

@@ -118,3 +118,17 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
} }
return false; 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: <explanation>
.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
);
}

View File

@@ -19,8 +19,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
indices: _indices = {}, indices: _indices = {},
} = this.config; } = this.config;
this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities));
const entities = transformObject(_entities, (entityConfig, name) => { const entities = transformObject(_entities, (entityConfig, name) => {
return constructEntity(name, entityConfig); return constructEntity(name, entityConfig);
}); });
@@ -60,7 +58,6 @@ export class AppData extends Module<typeof dataConfigSchema> {
); );
this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.ctx.logger.clear();
this.setBuilt(); this.setBuilt();
} }

View File

@@ -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<any>,
config: BaseIntrospectorConfig = {},
) {
this._excludeTables = config.excludeTables ?? [];
this._plugins = config.plugins ?? [new ParseJSONResultsPlugin()];
}
abstract getSchemaSpec(): Promise<SchemaSpec>;
abstract getSchemas(): Promise<SchemaMetadata[]>;
protected getExcludedTableNames(): string[] {
return [...this._excludeTables, DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE];
}
protected async executeWithPlugins<T>(query: RawBuilder<any>): Promise<T> {
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<DatabaseMetadata> {
return {
tables: await this.getTables(options),
};
}
async getIndices(tbl_name?: string): Promise<IndexMetadata[]> {
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<TableMetadata[]> {
const schema = await this.getSchemaSpec();
return schema.map((table) => ({
name: table.name,
isView: table.isView,
columns: table.columns,
}));
}
}

View File

@@ -1,15 +1,18 @@
import { import {
type AliasableExpression, type AliasableExpression,
type DatabaseIntrospector, type ColumnBuilderCallback,
type ColumnDataType,
type Expression, type Expression,
type Kysely, type Kysely,
type KyselyPlugin, type KyselyPlugin,
type OnModifyForeignAction,
type RawBuilder, type RawBuilder,
type SelectQueryBuilder, type SelectQueryBuilder,
type SelectQueryNode, type SelectQueryNode,
type Simplify, type Simplify,
sql, sql,
} from "kysely"; } from "kysely";
import type { BaseIntrospector } from "./BaseIntrospector";
export type QB = SelectQueryBuilder<any, any, any>; export type QB = SelectQueryBuilder<any, any, any>;
@@ -20,15 +23,43 @@ export type IndexMetadata = {
columns: { name: string; order: number }[]; columns: { name: string; order: number }[];
}; };
export interface ConnectionIntrospector extends DatabaseIntrospector {
getIndices(tbl_name?: string): Promise<IndexMetadata[]>;
}
export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O> { export interface SelectQueryBuilderExpression<O> extends AliasableExpression<O> {
get isSelectQueryBuilder(): true; get isSelectQueryBuilder(): true;
toOperationNode(): SelectQueryNode; 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 = { export type DbFunctions = {
jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>; jsonObjectFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O> | null>;
jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>; jsonArrayFrom<O>(expr: SelectQueryBuilderExpression<O>): RawBuilder<Simplify<O>[]>;
@@ -44,7 +75,11 @@ export type DbFunctions = {
const CONN_SYMBOL = Symbol.for("bknd:connection"); const CONN_SYMBOL = Symbol.for("bknd:connection");
export abstract class Connection<DB = any> { export abstract class Connection<DB = any> {
protected initialized = false;
kysely: Kysely<DB>; kysely: Kysely<DB>;
protected readonly supported = {
batching: false,
};
constructor( constructor(
kysely: Kysely<DB>, kysely: Kysely<DB>,
@@ -55,6 +90,11 @@ export abstract class Connection<DB = any> {
this[CONN_SYMBOL] = true; this[CONN_SYMBOL] = true;
} }
// @todo: consider moving constructor logic here, required by sqlocal
async init(): Promise<void> {
this.initialized = true;
}
/** /**
* This is a helper function to manage Connection classes * This is a helper function to manage Connection classes
* coming from different places * coming from different places
@@ -65,17 +105,12 @@ export abstract class Connection<DB = any> {
return conn[CONN_SYMBOL] === true; return conn[CONN_SYMBOL] === true;
} }
getIntrospector(): ConnectionIntrospector { getIntrospector(): BaseIntrospector {
return this.kysely.introspection as ConnectionIntrospector; return this.kysely.introspection as any;
} }
supportsBatching(): boolean { supports(feature: keyof typeof this.supported): boolean {
return false; return this.supported[feature] ?? false;
}
// @todo: add if only first field is used in index
supportsIndices(): boolean {
return false;
} }
async ping(): Promise<boolean> { async ping(): Promise<boolean> {
@@ -97,7 +132,7 @@ export abstract class Connection<DB = any> {
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>; [K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> { }> {
// bypass if no client support // bypass if no client support
if (!this.supportsBatching()) { if (!this.supports("batching")) {
const data: any = []; const data: any = [];
for (const q of queries) { for (const q of queries) {
const result = await q.execute(); const result = await q.execute();
@@ -108,4 +143,19 @@ export abstract class Connection<DB = any> {
return await this.batch(queries); 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<void> {
// no-op by default
}
} }

View File

@@ -1,7 +1,15 @@
import { Connection } from "./Connection"; import { Connection, type FieldSpec, type SchemaResponse } from "./Connection";
export class DummyConnection extends Connection { export class DummyConnection extends Connection {
protected override readonly supported = {
batching: true,
};
constructor() { constructor() {
super(undefined as any); super(undefined as any);
} }
override getFieldSchema(spec: FieldSpec, strict?: boolean): SchemaResponse {
throw new Error("Method not implemented.");
}
} }

View File

@@ -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<any>, fn: Partial<DbFunctions> = {}, plugins: KyselyPlugin[] = []) {
super(
kysely,
{
...fn,
jsonArrayFrom,
jsonObjectFrom,
jsonBuildObject,
},
plugins,
);
}
override supportsIndices(): boolean {
return true;
}
}

View File

@@ -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<any>;
readonly _excludeTables: string[] = [];
constructor(db: Kysely<any>, config: SqliteIntrospectorConfig = {}) {
this.#db = db;
this._excludeTables = config.excludeTables ?? [];
}
async getSchemas(): Promise<SchemaMetadata[]> {
// Sqlite doesn't support schemas.
return [];
}
async getIndices(tbl_name?: string): Promise<IndexMetadata[]> {
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<IndexMetadata> {
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<any, any>) => {
const and = tables.map((t) => eb("name", "!=", t));
return eb.and(and);
};
}
async getTables(
options: DatabaseMetadataOptions = { withInternalKyselyTables: false },
): Promise<TableMetadata[]> {
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<DatabaseMetadata> {
return {
tables: await this.getTables(options),
};
}
async #getTableMetadata(table: string): Promise<TableMetadata> {
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,
})),
};
}
}

View File

@@ -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";

View File

@@ -1,9 +1,9 @@
import { type Client, type Config, type InStatement, createClient } from "@libsql/client"; import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
import { LibsqlDialect } from "@libsql/kysely-libsql"; 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 { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin"; import type { QB } from "../Connection";
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
import type { QB } from "./Connection";
import { SqliteConnection } from "./SqliteConnection"; import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector"; import { SqliteIntrospector } from "./SqliteIntrospector";
@@ -12,21 +12,26 @@ export type LibSqlCredentials = Config & {
protocol?: (typeof LIBSQL_PROTOCOLS)[number]; protocol?: (typeof LIBSQL_PROTOCOLS)[number];
}; };
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
class CustomLibsqlDialect extends LibsqlDialect { class CustomLibsqlDialect extends LibsqlDialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector { override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, { return new SqliteIntrospector(db, {
excludeTables: ["libsql_wasm_func_table"], excludeTables: ["libsql_wasm_func_table"],
plugins,
}); });
} }
} }
export class LibsqlConnection extends SqliteConnection { export class LibsqlConnection extends SqliteConnection {
private client: Client; private client: Client;
protected override readonly supported = {
batching: true,
};
constructor(client: Client); constructor(client: Client);
constructor(credentials: LibSqlCredentials); constructor(credentials: LibSqlCredentials);
constructor(clientOrCredentials: Client | LibSqlCredentials) { constructor(clientOrCredentials: Client | LibSqlCredentials) {
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
let client: Client; let client: Client;
if (clientOrCredentials && "url" in clientOrCredentials) { if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials; let { url, authToken, protocol } = clientOrCredentials;
@@ -51,14 +56,6 @@ export class LibsqlConnection extends SqliteConnection {
this.client = client; this.client = client;
} }
override supportsBatching(): boolean {
return true;
}
override supportsIndices(): boolean {
return true;
}
getClient(): Client { getClient(): Client {
return this.client; return this.client;
} }

View File

@@ -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<any>, fn: Partial<DbFunctions> = {}, 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;
}
}

View File

@@ -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<SchemaMetadata[]> {
// 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<SqliteSchemaSpec[]>(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,
})),
})),
}));
}
}

View File

@@ -1,30 +1,31 @@
import { type DatabaseIntrospector, ParseJSONResultsPlugin, type SqliteDatabase } from "kysely"; import {
import { Kysely, SqliteDialect } from "kysely"; type DatabaseIntrospector,
Kysely,
ParseJSONResultsPlugin,
type SqliteDatabase,
SqliteDialect,
} from "kysely";
import { SqliteConnection } from "./SqliteConnection"; import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector"; import { SqliteIntrospector } from "./SqliteIntrospector";
const plugins = [new ParseJSONResultsPlugin()];
class CustomSqliteDialect extends SqliteDialect { class CustomSqliteDialect extends SqliteDialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector { override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, { return new SqliteIntrospector(db, {
excludeTables: ["test_table"], excludeTables: ["test_table"],
plugins,
}); });
} }
} }
export class SqliteLocalConnection extends SqliteConnection { export class SqliteLocalConnection extends SqliteConnection {
constructor(private database: SqliteDatabase) { constructor(private database: SqliteDatabase) {
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({ const kysely = new Kysely({
dialect: new CustomSqliteDialect({ database }), dialect: new CustomSqliteDialect({ database }),
plugins, plugins,
//log: ["query"],
}); });
super(kysely); super(kysely, {}, plugins);
this.plugins = plugins;
}
override supportsIndices(): boolean {
return true;
} }
} }

View File

@@ -167,7 +167,9 @@ export class Mutator<
const res = await this.single(query); 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; return res as any;
} }
@@ -198,7 +200,12 @@ export class Mutator<
const res = await this.single(query); const res = await this.single(query);
await this.emgr.emit( 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; return res as any;

View File

@@ -14,6 +14,7 @@ import {
WithBuilder, WithBuilder,
} from "../index"; } from "../index";
import { JoinBuilder } from "./JoinBuilder"; import { JoinBuilder } from "./JoinBuilder";
import { ensureInt } from "core/utils";
export type RepositoryQB = SelectQueryBuilder<any, any, any>; export type RepositoryQB = SelectQueryBuilder<any, any, any>;
@@ -225,8 +226,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
data, data,
meta: { meta: {
...payload.meta, ...payload.meta,
total: _total[0]?.total ?? 0, // parsing is important since pg returns string
count: _count[0]?.count ?? 0, // @todo: better graceful method total: ensureInt(_total[0]?.total),
count: ensureInt(_count[0]?.count),
items: result.length, items: result.length,
time, time,
}, },

View File

@@ -18,7 +18,11 @@ export class MutatorInsertBefore extends Event<{ entity: Entity; data: EntityDat
}); });
} }
} }
export class MutatorInsertAfter extends Event<{ entity: Entity; data: EntityData }> { export class MutatorInsertAfter extends Event<{
entity: Entity;
data: EntityData;
changed: EntityData;
}> {
static override slug = "mutator-insert-after"; static override slug = "mutator-insert-after";
} }
export class MutatorUpdateBefore extends Event< export class MutatorUpdateBefore extends Event<
@@ -48,6 +52,7 @@ export class MutatorUpdateAfter extends Event<{
entity: Entity; entity: Entity;
entityId: PrimaryFieldType; entityId: PrimaryFieldType;
data: EntityData; data: EntityData;
changed: EntityData;
}> { }> {
static override slug = "mutator-update-after"; static override slug = "mutator-update-after";
} }

View File

@@ -32,9 +32,11 @@ export class BooleanField<Required extends true | false = false> extends Field<
} }
} }
schema() { override schema() {
// @todo: potentially use "integer" instead return Object.freeze({
return this.useSchemaHelper("boolean"); ...super.schema()!,
type: "boolean",
});
} }
override getHtmlConfig() { override getHtmlConfig() {

View File

@@ -32,8 +32,10 @@ export class DateField<Required extends true | false = false> extends Field<
} }
override schema() { override schema() {
const type = this.config.type === "datetime" ? "datetime" : "date"; return Object.freeze({
return this.useSchemaHelper(type); ...super.schema()!,
type: this.config.type === "datetime" ? "datetime" : "date",
});
} }
override getHtmlConfig() { override getHtmlConfig() {

View File

@@ -66,10 +66,6 @@ export class EnumField<Required extends true | false = false, TypeOverride = str
return enumFieldConfigSchema; return enumFieldConfigSchema;
} }
override schema() {
return this.useSchemaHelper("text");
}
getOptions(): { label: string; value: string }[] { getOptions(): { label: string; value: string }[] {
const options = this.config?.options ?? { type: "strings", values: [] }; const options = this.config?.options ?? { type: "strings", values: [] };

View File

@@ -1,16 +1,16 @@
import { import {
parse,
snakeToPascalWithSpaces,
type Static, type Static,
StringEnum, StringEnum,
type TSchema, type TSchema,
Type, Type,
TypeInvalidError, TypeInvalidError,
parse,
snakeToPascalWithSpaces,
} from "core/utils"; } from "core/utils";
import type { ColumnBuilderCallback, ColumnDataType, ColumnDefinitionBuilder } from "kysely";
import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react"; import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import type { EntityManager } from "../entities"; import type { EntityManager } from "../entities";
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors"; import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
import type { FieldSpec } from "data/connection/Connection";
// @todo: contexts need to be reworked // @todo: contexts need to be reworked
// e.g. "table" is irrelevant, because if read is not given, it fails // e.g. "table" is irrelevant, because if read is not given, it fails
@@ -67,8 +67,6 @@ export const baseFieldConfigSchema = Type.Object(
); );
export type BaseFieldConfig = Static<typeof baseFieldConfigSchema>; export type BaseFieldConfig = Static<typeof baseFieldConfigSchema>;
export type SchemaResponse = [string, ColumnDataType, ColumnBuilderCallback] | undefined;
export abstract class Field< export abstract class Field<
Config extends BaseFieldConfig = BaseFieldConfig, Config extends BaseFieldConfig = BaseFieldConfig,
Type = any, Type = any,
@@ -106,25 +104,18 @@ export abstract class Field<
protected abstract getSchema(): TSchema; 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 * Used in SchemaManager.ts
* @param em * @param em
*/ */
abstract schema(em: EntityManager<any>): SchemaResponse; schema(): FieldSpec | undefined {
return Object.freeze({
name: this.name,
type: "text",
nullable: true,
dflt: this.getDefault(),
});
}
hasDefault() { hasDefault() {
return this.config.default_value !== undefined; return this.config.default_value !== undefined;

View File

@@ -18,10 +18,6 @@ export class JsonField<Required extends true | false = false, TypeOverride = obj
return jsonFieldConfigSchema; return jsonFieldConfigSchema;
} }
override schema() {
return this.useSchemaHelper("text");
}
/** /**
* Transform value after retrieving from database * Transform value after retrieving from database
* @param value * @param value

View File

@@ -36,10 +36,6 @@ export class JsonSchemaField<
return jsonSchemaFieldConfigSchema; return jsonSchemaFieldConfigSchema;
} }
override schema() {
return this.useSchemaHelper("text");
}
getJsonSchema(): JsonSchema { getJsonSchema(): JsonSchema {
return this.config?.schema as JsonSchema; return this.config?.schema as JsonSchema;
} }

View File

@@ -44,8 +44,11 @@ export class NumberField<Required extends true | false = false> extends Field<
}; };
} }
schema() { override schema() {
return this.useSchemaHelper("integer"); return Object.freeze({
...super.schema()!,
type: "integer",
});
} }
override getValue(value: any, context?: TRenderContext): any { override getValue(value: any, context?: TRenderContext): any {

View File

@@ -30,9 +30,12 @@ export class PrimaryField<Required extends true | false = false> extends Field<
return baseFieldConfigSchema; return baseFieldConfigSchema;
} }
schema() { override schema() {
return this.useSchemaHelper("integer", (col) => { return Object.freeze({
return col.primaryKey().notNull().autoIncrement(); type: "integer",
name: this.name,
primary: true,
nullable: false,
}); });
} }

View File

@@ -47,10 +47,6 @@ export class TextField<Required extends true | false = false> extends Field<
return textFieldConfigSchema; return textFieldConfigSchema;
} }
override schema() {
return this.useSchemaHelper("text");
}
override getHtmlConfig() { override getHtmlConfig() {
if (this.config.html_config) { if (this.config.html_config) {
return this.config.html_config as any; return this.config.html_config as any;

View File

@@ -17,7 +17,7 @@ export class VirtualField extends Field<VirtualFieldConfig> {
return virtualFieldConfigSchema; return virtualFieldConfigSchema;
} }
schema() { override schema() {
return undefined; return undefined;
} }

View File

@@ -5,6 +5,7 @@ export * from "./entities";
export * from "./relations"; export * from "./relations";
export * from "./schema/SchemaManager"; export * from "./schema/SchemaManager";
export * from "./prototype"; export * from "./prototype";
export * from "./connection";
export { export {
type RepoQuery, type RepoQuery,
@@ -14,11 +15,6 @@ export {
whereSchema, whereSchema,
} from "./server/data-query-impl"; } 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 { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
export { constructEntity, constructRelation } from "./schema/constructor"; export { constructEntity, constructRelation } from "./schema/constructor";

View File

@@ -1,6 +1,6 @@
import { type Static, StringEnum, Type } from "core/utils"; import { type Static, StringEnum, Type } from "core/utils";
import type { EntityManager } from "../entities"; import type { EntityManager } from "../entities";
import { Field, type SchemaResponse, baseFieldConfigSchema } from "../fields"; import { Field, baseFieldConfigSchema } from "../fields";
import type { EntityRelation } from "./EntityRelation"; import type { EntityRelation } from "./EntityRelation";
import type { EntityRelationAnchor } from "./EntityRelationAnchor"; import type { EntityRelationAnchor } from "./EntityRelationAnchor";
@@ -72,14 +72,12 @@ export class RelationField extends Field<RelationFieldConfig> {
return this.config.target_field!; return this.config.target_field!;
} }
override schema(): SchemaResponse { override schema() {
return this.useSchemaHelper("integer", (col) => { return Object.freeze({
//col.references('person.id').onDelete('cascade').notNull() ...super.schema()!,
// @todo: implement cascading? type: "integer",
references: `${this.config.target}.${this.config.target_field}`,
return col onDelete: this.config.on_delete ?? "set null",
.references(`${this.config.target}.${this.config.target_field}`)
.onDelete(this.config.on_delete ?? "set null");
}); });
} }

View File

@@ -1,7 +1,7 @@
import type { AlterTableColumnAlteringBuilder, CompiledQuery, TableMetadata } from "kysely"; import type { CompiledQuery, TableMetadata } from "kysely";
import type { IndexMetadata } from "../connection/Connection"; import type { IndexMetadata, SchemaResponse } from "../connection/Connection";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import { PrimaryField, type SchemaResponse } from "../fields"; import { PrimaryField } from "../fields";
type IntrospectedTable = TableMetadata & { type IntrospectedTable = TableMetadata & {
indices: IndexMetadata[]; indices: IndexMetadata[];
@@ -49,10 +49,6 @@ export class SchemaManager {
constructor(private readonly em: EntityManager<any>) {} constructor(private readonly em: EntityManager<any>) {}
private getIntrospector() { private getIntrospector() {
if (!this.em.connection.supportsIndices()) {
throw new Error("Indices are not supported by the current connection");
}
return this.em.connection.getIntrospector(); return this.em.connection.getIntrospector();
} }
@@ -239,10 +235,9 @@ export class SchemaManager {
for (const column of columns) { for (const column of columns) {
const field = this.em.entity(table).getField(column)!; const field = this.em.entity(table).getField(column)!;
const fieldSchema = field.schema(this.em); const fieldSchema = field.schema();
if (Array.isArray(fieldSchema) && fieldSchema.length === 3) { if (fieldSchema) {
schemas.push(fieldSchema); schemas.push(this.em.connection.getFieldSchema(fieldSchema));
//throw new Error(`Field "${field.name}" on entity "${table}" has no schema`);
} }
} }
@@ -330,6 +325,7 @@ export class SchemaManager {
if (local_updates === 0) continue; if (local_updates === 0) continue;
// iterate through built qbs // iterate through built qbs
// @todo: run in batches
for (const qb of qbs) { for (const qb of qbs) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
statements.push({ sql, parameters }); statements.push({ sql, parameters });

View File

@@ -47,7 +47,7 @@ export class MediaField<
return this.config.min_items; return this.config.min_items;
} }
schema() { override schema() {
return undefined; return undefined;
} }

View File

@@ -118,14 +118,20 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
const res = await this.fetch(url, { const res = await this.fetch(url, {
method: "PUT", method: "PUT",
body, body,
headers: isFile(body)
? {
// required for node environments
"Content-Length": String(body.size),
}
: {},
}); });
if (res.ok) { if (!res.ok) {
// "df20fcb574dba1446cf5ec997940492b" throw new Error(`Failed to upload object: ${res.status} ${res.statusText}`);
return String(res.headers.get("etag"));
} }
return undefined; // "df20fcb574dba1446cf5ec997940492b"
return String(res.headers.get("etag"));
} }
private async headObject( private async headObject(

View File

@@ -115,7 +115,7 @@ const configJsonSchema = Type.Union([
}), }),
), ),
]); ]);
const __bknd = entity(TABLE_NAME, { export const __bknd = entity(TABLE_NAME, {
version: number().required(), version: number().required(),
type: enumm({ enum: ["config", "diff", "backup"] }).required(), type: enumm({ enum: ["config", "diff", "backup"] }).required(),
json: jsonSchema({ schema: configJsonSchema }).required(), json: jsonSchema({ schema: configJsonSchema }).required(),
@@ -170,6 +170,8 @@ export class ModuleManager {
} }
} }
this.logger.log("booted with", this._booted_with);
this.createModules(initial); this.createModules(initial);
} }
@@ -218,7 +220,8 @@ export class ModuleManager {
private repo() { private repo() {
return this.__em.repo(__bknd, { 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<ConfigTable> { private async fetch(): Promise<ConfigTable | undefined> {
this.logger.context("fetch").log("fetching"); this.logger.context("fetch").log("fetching");
const startTime = performance.now(); const startTime = performance.now();
@@ -285,7 +288,7 @@ export class ModuleManager {
if (!result) { if (!result) {
this.logger.log("error fetching").clear(); this.logger.log("error fetching").clear();
throw BkndError.with("no config"); return undefined;
} }
this.logger this.logger
@@ -305,6 +308,7 @@ export class ModuleManager {
try { try {
const state = await this.fetch(); const state = await this.fetch();
if (!state) throw new BkndError("save: no config found");
this.logger.log("fetched version", state.version); this.logger.log("fetched version", state.version);
if (state.version !== version) { if (state.version !== version) {
@@ -321,11 +325,11 @@ export class ModuleManager {
json: configs, json: configs,
}); });
} else { } else {
this.logger.log("version matches"); this.logger.log("version matches", state.version);
// clean configs because of Diff() function // clean configs because of Diff() function
const diffs = diff(state.json, clone(configs)); const diffs = diff(state.json, clone(configs));
this.logger.log("checking diff", diffs); this.logger.log("checking diff", [diffs.length]);
if (diff.length > 0) { if (diff.length > 0) {
// store diff // store diff
@@ -380,78 +384,6 @@ export class ModuleManager {
return this; 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 { private setConfigs(configs: ModuleConfigs): void {
this.logger.log("setting configs"); this.logger.log("setting configs");
objectEach(configs, (config, key) => { objectEach(configs, (config, key) => {
@@ -469,66 +401,67 @@ export class ModuleManager {
async build(opts?: { fetch?: boolean }) { async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version()); 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 no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) { if (this.version() === 0 || opts?.fetch === true) {
if (this.version() === 0) { if (opts?.fetch) {
this.logger.context("no version").log("version is 0"); this.logger.log("force fetch");
} else {
this.logger.context("force fetch").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 // set version and config from fetched
this._version = result.version; this._version = result.version;
if (this.version() !== CURRENT_VERSION) {
await this.syncConfigTable();
}
if (this.options?.trustFetched === true) { if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)"); this.logger.log("trusting fetched config (mark)");
mark(result.json); mark(result.json);
} }
this.setConfigs(result.json); // if version doesn't match, migrate before building
} catch (e: any) { if (this.version() !== CURRENT_VERSION) {
this.logger.clear(); // fetch couldn't clear 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 const version_before = this.version();
// it's up to date because we use default configs (no fetch result) const [_version, _configs] = await migrate(version_before, result.json, {
this._version = CURRENT_VERSION; db: this.db,
await this.syncConfigTable(); });
const state = await this.buildModules();
if (!state.saved) { this._version = _version;
await this.save(); 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 { } 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(); await this.buildModules();
} }
this.logger.log("done"); this.logger.log("done");
this.logger.clear();
return this; return this;
} }
@@ -589,6 +522,14 @@ export class ModuleManager {
} }
protected async setupInitial() { 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 = { const ctx = {
...this.ctx(), ...this.ctx(),
// disable events for initial setup // disable events for initial setup
@@ -601,6 +542,7 @@ export class ModuleManager {
// run first boot event // run first boot event
await this.options?.onFirstBoot?.(); await this.options?.onFirstBoot?.();
this.logger.clear();
} }
mutateConfigSafe<Module extends keyof Modules>( mutateConfigSafe<Module extends keyof Modules>(

View File

@@ -1,6 +1,7 @@
import { _jsonp, transformObject } from "core/utils"; import { _jsonp, transformObject } from "core/utils";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
import { set } from "lodash-es"; import { set } from "lodash-es";
import type { InitialModuleConfigs } from "modules/ModuleManager";
export type MigrationContext = { export type MigrationContext = {
db: Kysely<any>; db: Kysely<any>;
@@ -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; export const CURRENT_VERSION = migrations[migrations.length - 1]?.version ?? 0;

View File

@@ -5,9 +5,9 @@ import { config, isDebug } from "core";
import { addFlashMessage } from "core/server/flash"; import { addFlashMessage } from "core/server/flash";
import { html } from "hono/html"; import { html } from "hono/html";
import { Fragment } from "hono/jsx"; import { Fragment } from "hono/jsx";
import { css, Style } from "hono/css";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import type { AppTheme } from "modules/server/AppServer";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->"; const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -73,7 +73,6 @@ export class AdminController extends Controller {
const obj = { const obj = {
user: c.get("auth")?.user, user: c.get("auth")?.user,
logout_route: this.withBasePath(authRoutes.logout), logout_route: this.withBasePath(authRoutes.logout),
color_scheme: configs.server.admin.color_scheme,
}; };
const html = await this.getHtml(obj); const html = await this.getHtml(obj);
if (!html) { if (!html) {
@@ -183,14 +182,13 @@ export class AdminController extends Controller {
assets.css = manifest["src/ui/main.tsx"].css[0] as any; 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"; const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico";
return ( return (
<Fragment> <Fragment>
{/* dnd complains otherwise */} {/* dnd complains otherwise */}
{html`<!DOCTYPE html>`} {html`<!DOCTYPE html>`}
<html lang="en" class={theme}> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
@@ -229,10 +227,9 @@ export class AdminController extends Controller {
</head> </head>
<body> <body>
<div id="root"> <div id="root">
<div id="loading" style={style(theme)}> <Style />
<span style={{ opacity: 0.3, fontSize: 14, fontFamily: "monospace" }}> <div id="loading" className={wrapperStyle}>
Initializing... <span className={loaderStyle}>Initializing...</span>
</span>
</div> </div>
</div> </div>
<script <script
@@ -248,31 +245,27 @@ export class AdminController extends Controller {
} }
} }
const style = (theme: AppTheme) => { const wrapperStyle = css`
const base = { margin: 0;
margin: 0, padding: 0;
padding: 0, height: 100vh;
height: "100vh", width: 100vw;
width: "100vw", display: flex;
display: "flex", justify-content: center;
justifyContent: "center", align-items: center;
alignItems: "center", -webkit-font-smoothing: antialiased;
"-webkit-font-smoothing": "antialiased", -moz-osx-font-smoothing: grayscale;
"-moz-osx-font-smoothing": "grayscale", color: rgb(9,9,11);
}; background-color: rgb(250,250,250);
const styles = {
light: { @media (prefers-color-scheme: dark) {
color: "rgb(9,9,11)", color: rgb(250,250,250);
backgroundColor: "rgb(250,250,250)", background-color: rgb(30,31,34);
}, }
dark: { `;
color: "rgb(250,250,250)",
backgroundColor: "rgb(30,31,34)",
},
};
return { const loaderStyle = css`
...base, opacity: 0.3;
...styles[theme === "light" ? "light" : "dark"], font-size: 14px;
}; font-family: monospace;
}; `;

View File

@@ -1,27 +1,12 @@
import { Exception } from "core"; import { Exception, isDebug } from "core";
import { type Static, StringEnum, Type } from "core/utils"; import { type Static, StringEnum, Type } from "core/utils";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
const appThemes = ["dark", "light", "system"] as const;
export type AppTheme = (typeof appThemes)[number];
export const serverConfigSchema = Type.Object( export const serverConfigSchema = Type.Object(
{ {
admin: Type.Object(
{
basepath: Type.Optional(Type.String({ default: "", pattern: "^(/.+)?$" })),
color_scheme: Type.Optional(StringEnum(["dark", "light", "system"])),
logo_return_path: Type.Optional(
Type.String({
default: "/",
description: "Path to return to after *clicking* the logo",
}),
),
},
{ default: {}, additionalProperties: false },
),
cors: Type.Object( cors: Type.Object(
{ {
origin: Type.String({ default: "*" }), origin: Type.String({ default: "*" }),
@@ -43,12 +28,6 @@ export const serverConfigSchema = Type.Object(
export type AppServerConfig = Static<typeof serverConfigSchema>; export type AppServerConfig = Static<typeof serverConfigSchema>;
/*declare global {
interface Request {
cf: IncomingRequestCfProperties;
}
}*/
export class AppServer extends Module<typeof serverConfigSchema> { export class AppServer extends Module<typeof serverConfigSchema> {
//private admin_html?: string; //private admin_html?: string;
@@ -102,6 +81,12 @@ export class AppServer extends Module<typeof serverConfigSchema> {
return c.json(err.toJSON(), err.code as any); return c.json(err.toJSON(), err.code as any);
} }
if (err instanceof Error) {
if (isDebug()) {
return c.json({ error: err.message, stack: err.stack }, 500);
}
}
return c.json({ error: err.message }, 500); return c.json({ error: err.message }, 500);
}); });
this.setBuilt(); this.setBuilt();

View File

@@ -1,12 +1,10 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "modules";
import React from "react"; import React from "react";
import { BkndProvider, useBknd } from "ui/client/bknd"; import { BkndProvider, type BkndAdminOptions } from "ui/client/bknd";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Logo } from "ui/components/display/Logo"; import { Logo } from "ui/components/display/Logo";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { ClientProvider, type ClientProviderProps } from "./client"; import { ClientProvider, type ClientProviderProps } from "./client";
import { createMantineTheme } from "./lib/mantine/theme"; import { createMantineTheme } from "./lib/mantine/theme";
import { BkndModalsProvider } from "./modals"; import { BkndModalsProvider } from "./modals";
@@ -15,7 +13,7 @@ import { Routes } from "./routes";
export type BkndAdminProps = { export type BkndAdminProps = {
baseUrl?: string; baseUrl?: string;
withProvider?: boolean | ClientProviderProps; withProvider?: boolean | ClientProviderProps;
config?: ModuleConfigs["server"]["admin"]; config?: BkndAdminOptions;
}; };
export default function Admin({ export default function Admin({
@@ -24,7 +22,7 @@ export default function Admin({
config, config,
}: BkndAdminProps) { }: BkndAdminProps) {
const Component = ( const Component = (
<BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}> <BkndProvider options={config} fallback={<Skeleton theme={config?.theme} />}>
<AdminInternal /> <AdminInternal />
</BkndProvider> </BkndProvider>
); );
@@ -46,7 +44,6 @@ function AdminInternal() {
return ( return (
<MantineProvider {...createMantineTheme(theme as any)}> <MantineProvider {...createMantineTheme(theme as any)}>
<Notifications position="top-right" /> <Notifications position="top-right" />
<FlashMessage />
<BkndModalsProvider> <BkndModalsProvider>
<Routes /> <Routes />
</BkndModalsProvider> </BkndModalsProvider>

View File

@@ -4,7 +4,13 @@ import { createContext, startTransition, useContext, useEffect, useRef, useState
import { useApi } from "ui/client"; import { useApi } from "ui/client";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
import { AppReduced } from "./utils/AppReduced"; import { AppReduced } from "./utils/AppReduced";
import type { AppTheme } from "ui/client/use-theme";
export type BkndAdminOptions = {
logo_return_path?: string;
basepath?: string;
theme?: AppTheme;
};
type BkndContext = { type BkndContext = {
version: number; version: number;
schema: ModuleSchemas; schema: ModuleSchemas;
@@ -14,7 +20,7 @@ type BkndContext = {
requireSecrets: () => Promise<void>; requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;
app: AppReduced; app: AppReduced;
adminOverride?: ModuleConfigs["server"]["admin"]; options: BkndAdminOptions;
fallback: boolean; fallback: boolean;
}; };
@@ -29,19 +35,21 @@ enum Fetching {
export function BkndProvider({ export function BkndProvider({
includeSecrets = false, includeSecrets = false,
adminOverride, options,
children, children,
fallback = null, fallback = null,
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick< }: {
BkndContext, includeSecrets?: boolean;
"adminOverride" children: any;
>) { fallback?: React.ReactNode;
options?: BkndAdminOptions;
}) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets); const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] = const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>(); useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions" | "fallback">>();
const [fetched, setFetched] = useState(false); const [fetched, setFetched] = useState(false);
const [error, setError] = useState<boolean>(); const [error, setError] = useState<boolean>();
const errorShown = useRef<boolean>(); const errorShown = useRef<boolean>(false);
const fetching = useRef<Fetching>(Fetching.None); const fetching = useRef<Fetching>(Fetching.None);
const [local_version, set_local_version] = useState(0); const [local_version, set_local_version] = useState(0);
const api = useApi(); const api = useApi();
@@ -93,19 +101,14 @@ export function BkndProvider({
fallback: true, fallback: true,
} as any); } as any);
if (adminOverride) {
newSchema.config.server.admin = {
...newSchema.config.server.admin,
...adminOverride,
};
}
startTransition(() => { startTransition(() => {
setSchema(newSchema); document.startViewTransition(() => {
setWithSecrets(_includeSecrets); setSchema(newSchema);
setFetched(true); setWithSecrets(_includeSecrets);
set_local_version((v) => v + 1); setFetched(true);
fetching.current = Fetching.None; set_local_version((v) => v + 1);
fetching.current = Fetching.None;
});
}); });
} }
@@ -120,13 +123,13 @@ export function BkndProvider({
}, []); }, []);
if (!fetched || !schema) return fallback; if (!fetched || !schema) return fallback;
const app = new AppReduced(schema?.config as any); const app = new AppReduced(schema?.config as any, options);
const actions = getSchemaActions({ api, setSchema, reloadSchema }); const actions = getSchemaActions({ api, setSchema, reloadSchema });
const hasSecrets = withSecrets && !error; const hasSecrets = withSecrets && !error;
return ( return (
<BkndContext.Provider <BkndContext.Provider
value={{ ...schema, actions, requireSecrets, app, adminOverride, hasSecrets }} value={{ ...schema, actions, requireSecrets, app, options: app.options, hasSecrets }}
key={local_version} key={local_version}
> >
{/*{error && ( {/*{error && (
@@ -151,3 +154,12 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo
return ctx; return ctx;
} }
export function useBkndOptions(): BkndAdminOptions {
const ctx = useContext(BkndContext);
return (
ctx.options ?? {
basepath: "/",
}
);
}

View File

@@ -1,39 +1,48 @@
import { Api, type ApiOptions, type TApiUser } from "Api"; import { Api, type ApiOptions, type TApiUser } from "Api";
import { isDebug } from "core"; import { isDebug } from "core";
import type { AppTheme } from "modules/server/AppServer"; import { createContext, type ReactNode, useContext } from "react";
import { createContext, useContext } from "react";
const ClientContext = createContext<{ baseUrl: string; api: Api }>({ const ClientContext = createContext<{ baseUrl: string; api: Api }>({
baseUrl: undefined, baseUrl: undefined,
} as any); } as any);
export type ClientProviderProps = { export type ClientProviderProps = {
children?: any; children?: ReactNode;
baseUrl?: string; } & (
user?: TApiUser | null | undefined; | { baseUrl?: string; user?: TApiUser | null | undefined }
}; | {
api: Api;
}
);
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => { export const ClientProvider = ({ children, ...props }: ClientProviderProps) => {
const winCtx = useBkndWindowContext(); let api: Api;
const _ctx_baseUrl = useBaseUrl();
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
try { if (props && "api" in props) {
if (!baseUrl) { api = props.api;
if (_ctx_baseUrl) { } else {
actualBaseUrl = _ctx_baseUrl; const winCtx = useBkndWindowContext();
console.warn("wrapped many times, take from context", actualBaseUrl); const _ctx_baseUrl = useBaseUrl();
} else if (typeof window !== "undefined") { const { baseUrl, user } = props;
actualBaseUrl = window.location.origin; let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
//console.log("setting from window", actualBaseUrl);
try {
if (!baseUrl) {
if (_ctx_baseUrl) {
actualBaseUrl = _ctx_baseUrl;
console.warn("wrapped many times, take from context", actualBaseUrl);
} else if (typeof window !== "undefined") {
actualBaseUrl = window.location.origin;
//console.log("setting from window", actualBaseUrl);
}
} }
} catch (e) {
console.error("Error in ClientProvider", e);
} }
} catch (e) {
console.error("Error in ClientProvider", e);
}
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); //console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() }); api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() });
}
return ( return (
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}> <ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
@@ -62,7 +71,6 @@ export const useBaseUrl = () => {
type BkndWindowContext = { type BkndWindowContext = {
user?: TApiUser; user?: TApiUser;
logout_route: string; logout_route: string;
color_scheme?: AppTheme;
}; };
export function useBkndWindowContext(): BkndWindowContext { export function useBkndWindowContext(): BkndWindowContext {
if (typeof window !== "undefined" && window.__BKND__) { if (typeof window !== "undefined" && window.__BKND__) {

View File

@@ -1 +1 @@
export { BkndProvider, useBknd } from "./BkndProvider"; export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";

View File

@@ -29,13 +29,3 @@ export function useBkndSystem() {
actions, actions,
}; };
} }
export function useBkndSystemTheme() {
const $sys = useBkndSystem();
return {
theme: $sys.theme,
set: $sys.actions.theme.set,
toggle: () => $sys.actions.theme.toggle(),
};
}

View File

@@ -1,29 +1,49 @@
import type { AppTheme } from "modules/server/AppServer";
import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { create } from "zustand";
import { combine, persist } from "zustand/middleware";
const themes = ["dark", "light", "system"] as const;
export type AppTheme = (typeof themes)[number];
const themeStore = create(
persist(
combine({ theme: null as AppTheme | null }, (set) => ({
setTheme: (theme: AppTheme | any) => {
if (themes.includes(theme)) {
document.startViewTransition(() => {
set({ theme });
});
}
},
})),
{
name: "bknd-admin-theme",
},
),
);
export function useTheme(fallback: AppTheme = "system") { export function useTheme(fallback: AppTheme = "system") {
const b = useBknd(); const b = useBknd();
const winCtx = useBkndWindowContext(); const theme_state = themeStore((state) => state.theme);
const theme_set = themeStore((state) => state.setTheme);
// 1. override // 1. override
// 2. config // 2. local storage
// 3. winCtx // 3. fallback
// 4. fallback // 4. default
// 5. default const override = b?.options?.theme;
const override = b?.adminOverride?.color_scheme;
const config = b?.config.server.admin.color_scheme;
const win = winCtx.color_scheme;
const prefersDark = const prefersDark =
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches; typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = override ?? config ?? win ?? fallback; const theme = override ?? theme_state ?? fallback;
return { return {
theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme, theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme,
value: theme,
themes,
setTheme: theme_set,
state: theme_state,
prefersDark, prefersDark,
override, override,
config,
win,
}; };
} }

View File

@@ -2,6 +2,7 @@ import type { App } from "App";
import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data"; import { type Entity, type EntityRelation, constructEntity, constructRelation } from "data";
import { RelationAccessor } from "data/relations/RelationAccessor"; import { RelationAccessor } from "data/relations/RelationAccessor";
import { Flow, TaskMap } from "flows"; import { Flow, TaskMap } from "flows";
import type { BkndAdminOptions } from "ui/client/BkndProvider";
export type AppType = ReturnType<App["toJSON"]>; export type AppType = ReturnType<App["toJSON"]>;
@@ -15,7 +16,10 @@ export class AppReduced {
private _relations: EntityRelation[] = []; private _relations: EntityRelation[] = [];
private _flows: Flow[] = []; private _flows: Flow[] = [];
constructor(protected appJson: AppType) { constructor(
protected appJson: AppType,
protected _options: BkndAdminOptions = {},
) {
//console.log("received appjson", appJson); //console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
@@ -62,18 +66,21 @@ export class AppReduced {
return this.appJson; return this.appJson;
} }
getAdminConfig() { get options() {
return this.appJson.server.admin; return {
basepath: "",
logo_return_path: "/",
...this._options,
};
} }
getSettingsPath(path: string[] = []): string { getSettingsPath(path: string[] = []): string {
const { basepath } = this.getAdminConfig(); const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
const base = `~/${basepath}/settings`.replace(/\/+/g, "/");
return [base, ...path].join("/"); return [base, ...path].join("/");
} }
getAbsolutePath(path?: string): string { getAbsolutePath(path?: string): string {
const { basepath } = this.getAdminConfig(); const { basepath } = this.options;
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/"); return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
} }

View File

@@ -1,28 +0,0 @@
import { useState } from "react";
export type AppTheme = "light" | "dark" | string;
export function useSetTheme(initialTheme: AppTheme = "light") {
const [theme, _setTheme] = useState(initialTheme);
const $html = document.querySelector("#bknd-admin")!;
function setTheme(newTheme: AppTheme) {
$html?.classList.remove("dark", "light");
$html?.classList.add(newTheme);
_setTheme(newTheme);
// @todo: just a quick switcher config update test
fetch("/api/system/config/patch/server/admin", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ color_scheme: newTheme }),
})
.then((res) => res.json())
.then((data) => {
console.log("theme updated", data);
});
}
return { theme, setTheme };
}

View File

@@ -23,7 +23,7 @@ const styles = {
outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80", outline: "border border-primary/20 bg-transparent hover:bg-primary/5 link text-primary/80",
red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70", red: "dark:bg-red-950 dark:hover:bg-red-900 bg-red-100 hover:bg-red-200 link text-primary/70",
subtlered: subtlered:
"dark:text-red-950 text-red-700 dark:hover:bg-red-900 bg-transparent hover:bg-red-50 link", "dark:text-red-700 text-red-700 dark:hover:bg-red-900 dark:hover:text-red-200 bg-transparent hover:bg-red-50 link",
}; };
export type BaseProps = { export type BaseProps = {
@@ -51,7 +51,7 @@ const Base = ({
}: BaseProps) => ({ }: BaseProps) => ({
...props, ...props,
className: twMerge( className: twMerge(
"flex flex-row flex-nowrap items-center font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]", "flex flex-row flex-nowrap items-center !font-semibold disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed transition-[opacity,background-color,color,border-color]",
sizes[size ?? "default"], sizes[size ?? "default"],
styles[variant ?? "default"], styles[variant ?? "default"],
props.className, props.className,

View File

@@ -1,25 +1,23 @@
import { import {
Background, Background,
BackgroundVariant, BackgroundVariant,
MarkerType,
MiniMap, MiniMap,
type MiniMapProps, type MiniMapProps,
ReactFlow, ReactFlow,
type ReactFlowProps, type ReactFlowProps,
ReactFlowProvider,
addEdge, addEdge,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
useReactFlow, useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { type ReactNode, useCallback, useEffect, useState } from "react"; import { type ReactNode, useCallback, useEffect, useState } from "react";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system"; import { useTheme } from "ui/client/use-theme";
type CanvasProps = ReactFlowProps & { type CanvasProps = ReactFlowProps & {
externalProvider?: boolean; externalProvider?: boolean;
backgroundStyle?: "lines" | "dots"; backgroundStyle?: "lines" | "dots";
minimap?: boolean | MiniMapProps; minimap?: boolean | MiniMapProps;
children?: JSX.Element | ReactNode; children?: Element | ReactNode;
onDropNewNode?: (base: any) => any; onDropNewNode?: (base: any) => any;
onDropNewEdge?: (base: any) => any; onDropNewEdge?: (base: any) => any;
}; };
@@ -38,7 +36,7 @@ export function Canvas({
const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []); const [nodes, setNodes, onNodesChange] = useNodesState(_nodes ?? []);
const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []); const [edges, setEdges, onEdgesChange] = useEdgesState(_edges ?? []);
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const { theme } = useBkndSystemTheme(); const { theme } = useTheme();
const [isCommandPressed, setIsCommandPressed] = useState(false); const [isCommandPressed, setIsCommandPressed] = useState(false);
const [isSpacePressed, setIsSpacePressed] = useState(false); const [isSpacePressed, setIsSpacePressed] = useState(false);

View File

@@ -1,8 +1,7 @@
import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { default as CodeMirror, type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useBknd } from "ui/client/bknd";
import { json } from "@codemirror/lang-json"; import { json } from "@codemirror/lang-json";
import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid"; import { type LiquidCompletionConfig, liquid } from "@codemirror/lang-liquid";
import { useTheme } from "ui/client/use-theme";
export type CodeEditorProps = ReactCodeMirrorProps & { export type CodeEditorProps = ReactCodeMirrorProps & {
_extensions?: Partial<{ _extensions?: Partial<{
@@ -17,8 +16,7 @@ export default function CodeEditor({
_extensions = {}, _extensions = {},
...props ...props
}: CodeEditorProps) { }: CodeEditorProps) {
const b = useBknd(); const { theme } = useTheme();
const theme = b.app.getAdminConfig().color_scheme;
const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable const _basicSetup: Partial<ReactCodeMirrorProps["basicSetup"]> = !editable
? { ? {
...(typeof basicSetup === "object" ? basicSetup : {}), ...(typeof basicSetup === "object" ? basicSetup : {}),

View File

@@ -18,13 +18,7 @@ const Base: React.FC<AlertProps> = ({
...props ...props
}) => }) =>
visible ? ( visible ? (
<div <div {...props} className={twMerge("flex flex-row items-center p-4", className)}>
{...props}
className={twMerge(
"flex flex-row items-center dark:bg-amber-300/20 bg-amber-200 p-4",
className,
)}
>
<p> <p>
{title && <b>{title}: </b>} {title && <b>{title}: </b>}
{message || children} {message || children}
@@ -33,19 +27,19 @@ const Base: React.FC<AlertProps> = ({
) : null; ) : null;
const Warning: React.FC<AlertProps> = ({ className, ...props }) => ( const Warning: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-amber-300/20 bg-amber-200", className)} /> <Base {...props} className={twMerge("bg-warning text-warning-foreground", className)} />
); );
const Exception: React.FC<AlertProps> = ({ className, ...props }) => ( const Exception: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-red-950 bg-red-100", className)} /> <Base {...props} className={twMerge("bg-error text-error-foreground", className)} />
); );
const Success: React.FC<AlertProps> = ({ className, ...props }) => ( const Success: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-green-950 bg-green-100", className)} /> <Base {...props} className={twMerge("bg-success text-success-foreground", className)} />
); );
const Info: React.FC<AlertProps> = ({ className, ...props }) => ( const Info: React.FC<AlertProps> = ({ className, ...props }) => (
<Base {...props} className={twMerge("dark:bg-blue-950 bg-blue-100", className)} /> <Base {...props} className={twMerge("bg-info text-info-foreground", className)} />
); );
export const Alert = { export const Alert = {

View File

@@ -3,7 +3,6 @@ import { getBrowser } from "core/utils";
import type { Field } from "data"; import type { Field } from "data";
import { Switch as RadixSwitch } from "radix-ui"; import { Switch as RadixSwitch } from "radix-ui";
import { import {
type ChangeEventHandler,
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
type ElementType, type ElementType,
forwardRef, forwardRef,
@@ -12,7 +11,7 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import { TbCalendar, TbChevronDown, TbInfoCircle } from "react-icons/tb"; import { TbCalendar, TbChevronDown, TbEye, TbEyeOff, TbInfoCircle } from "react-icons/tb";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
@@ -89,7 +88,7 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
{...props} {...props}
ref={ref} ref={ref}
className={twMerge( className={twMerge(
"bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none", "bg-muted/40 h-11 rounded-md py-2.5 px-4 outline-none w-full",
disabledOrReadonly && "bg-muted/50 text-primary/50", disabledOrReadonly && "bg-muted/50 text-primary/50",
!disabledOrReadonly && !disabledOrReadonly &&
"focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all", "focus:bg-muted focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:border-transparent transition-all",
@@ -99,6 +98,40 @@ export const Input = forwardRef<HTMLInputElement, React.ComponentProps<"input">>
); );
}); });
export const TypeAwareInput = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => {
if (props.type === "password") {
return <Password {...props} ref={ref} />;
}
return <Input {...props} ref={ref} />;
},
);
export const Password = forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
(props, ref) => {
const [visible, setVisible] = useState(false);
function handleToggle() {
setVisible((v) => !v);
}
return (
<div className="relative w-full">
<Input {...props} type={visible ? "text" : "password"} className="w-full" ref={ref} />
<div className="absolute right-3 top-0 bottom-0 flex items-center">
<IconButton
Icon={visible ? TbEyeOff : TbEye}
onClick={handleToggle}
variant="ghost"
className="opacity-70"
/>
</div>
</div>
);
},
);
export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>( export const Textarea = forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
(props, ref) => { (props, ref) => {
return ( return (

View File

@@ -1,5 +1,5 @@
import type { JsonSchema } from "json-schema-library"; import type { JsonSchema } from "json-schema-library";
import type { ChangeEvent, ComponentPropsWithoutRef } from "react"; import type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from "react";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
@@ -13,6 +13,7 @@ export type FieldProps = {
onChange?: (e: ChangeEvent<any>) => void; onChange?: (e: ChangeEvent<any>) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
inputProps?: Partial<FieldComponentProps>;
} & Omit<FieldwrapperProps, "children" | "schema">; } & Omit<FieldwrapperProps, "children" | "schema">;
export const Field = (props: FieldProps) => { export const Field = (props: FieldProps) => {
@@ -31,7 +32,14 @@ const fieldErrorBoundary =
</Pre> </Pre>
); );
const FieldImpl = ({ name, onChange, placeholder, required: _required, ...props }: FieldProps) => { const FieldImpl = ({
name,
onChange,
placeholder,
required: _required,
inputProps,
...props
}: FieldProps) => {
const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name); const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name);
const required = typeof _required === "boolean" ? _required : ctx.required; const required = typeof _required === "boolean" ? _required : ctx.required;
//console.log("Field", { name, path, schema }); //console.log("Field", { name, path, schema });
@@ -64,6 +72,7 @@ const FieldImpl = ({ name, onChange, placeholder, required: _required, ...props
return ( return (
<FieldWrapper name={name} required={required} schema={schema} {...props}> <FieldWrapper name={name} required={required} schema={schema} {...props}>
<FieldComponent <FieldComponent
{...inputProps}
schema={schema} schema={schema}
name={name} name={name}
required={required} required={required}
@@ -81,10 +90,12 @@ export const Pre = ({ children }) => (
</pre> </pre>
); );
export const FieldComponent = ({ export type FieldComponentProps = {
schema, schema: JsonSchema;
..._props render?: (props: Omit<FieldComponentProps, "render">) => ReactNode;
}: { schema: JsonSchema } & ComponentPropsWithoutRef<"input">) => { } & ComponentPropsWithoutRef<"input">;
export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProps) => {
const { value } = useFormValue(_props.name!, { strict: true }); const { value } = useFormValue(_props.name!, { strict: true });
if (!isTypeSchema(schema)) return null; if (!isTypeSchema(schema)) return null;
const props = { const props = {
@@ -97,6 +108,8 @@ export const FieldComponent = ({
: "", : "",
}; };
if (render) return render({ schema, ...props });
if (schema.enum) { if (schema.enum) {
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />; return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
} }
@@ -158,5 +171,7 @@ export const FieldComponent = ({
} }
} }
return <Formy.Input id={props.name} {...props} value={props.value ?? ""} {...additional} />; return (
<Formy.TypeAwareInput id={props.name} {...props} value={props.value ?? ""} {...additional} />
);
}; };

View File

@@ -74,6 +74,7 @@ export default function ArrayFieldTemplate<
{items.map( {items.map(
({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => { ({ key, children, ...itemProps }: ArrayFieldTemplateItemType<T, S, F>) => {
const newChildren = cloneElement(children, { const newChildren = cloneElement(children, {
// @ts-ignore
...children.props, ...children.props,
name: undefined, name: undefined,
title: undefined, title: undefined,

View File

@@ -4,6 +4,7 @@ import {
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
Fragment, Fragment,
type ReactElement, type ReactElement,
type ReactNode,
cloneElement, cloneElement,
useState, useState,
} from "react"; } from "react";
@@ -11,7 +12,7 @@ import { twMerge } from "tailwind-merge";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
export type DropdownItem = export type DropdownItem =
| (() => JSX.Element) | (() => ReactNode)
| { | {
label: string | ReactElement; label: string | ReactElement;
icon?: any; icon?: any;

View File

@@ -7,10 +7,10 @@ import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy/components"; import { Group, Input, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink"; import { SocialLink } from "./SocialLink";
import type { ValueError } from "@sinclair/typebox/value"; import type { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils"; import { type TSchema, Value } from "core/utils";
import type { Validator } from "json-schema-form-react"; import type { Validator } from "json-schema-form-react";
import { useTheme } from "ui/client/use-theme";
class TypeboxValidator implements Validator<ValueError> { class TypeboxValidator implements Validator<ValueError> {
async validate(schema: TSchema, data: any) { async validate(schema: TSchema, data: any) {
@@ -46,6 +46,7 @@ export function AuthForm({
buttonLabel = action === "login" ? "Sign in" : "Sign up", buttonLabel = action === "login" ? "Sign in" : "Sign up",
...props ...props
}: LoginFormProps) { }: LoginFormProps) {
const { theme } = useTheme();
const basepath = auth?.basepath ?? "/api/auth"; const basepath = auth?.basepath ?? "/api/auth";
const password = { const password = {
action: `${basepath}/password/${action}`, action: `${basepath}/password/${action}`,

View File

@@ -2,6 +2,7 @@ import type { DB } from "core";
import { import {
type ComponentPropsWithRef, type ComponentPropsWithRef,
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
type ReactNode,
type RefObject, type RefObject,
memo, memo,
useEffect, useEffect,
@@ -27,7 +28,7 @@ export type FileState = {
export type FileStateWithData = FileState & { data: DB["media"] }; export type FileStateWithData = FileState & { data: DB["media"] };
export type DropzoneRenderProps = { export type DropzoneRenderProps = {
wrapperRef: RefObject<HTMLDivElement>; wrapperRef: RefObject<HTMLDivElement | null>;
inputProps: ComponentPropsWithRef<"input">; inputProps: ComponentPropsWithRef<"input">;
state: { state: {
files: FileState[]; files: FileState[];
@@ -59,7 +60,7 @@ export type DropzoneProps = {
show?: boolean; show?: boolean;
text?: string; text?: string;
}; };
children?: (props: DropzoneRenderProps) => JSX.Element; children?: (props: DropzoneRenderProps) => ReactNode;
}; };
function handleUploadError(e: unknown) { function handleUploadError(e: unknown) {
@@ -459,7 +460,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
export type PreviewComponentProps = { export type PreviewComponentProps = {
file: FileState; file: FileState;
fallback?: (props: { file: FileState }) => JSX.Element; fallback?: (props: { file: FileState }) => ReactNode;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
onTouchStart?: () => void; onTouchStart?: () => void;
@@ -486,7 +487,7 @@ type PreviewProps = {
handleUpload: (file: FileState) => Promise<void>; handleUpload: (file: FileState) => Promise<void>;
handleDelete: (file: FileState) => Promise<void>; handleDelete: (file: FileState) => Promise<void>;
}; };
const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) => { const Preview = ({ file, handleUpload, handleDelete }: PreviewProps) => {
const dropdownItems = [ const dropdownItems = [
["initial", "uploaded"].includes(file.state) && { ["initial", "uploaded"].includes(file.state) && {
label: "Delete", label: "Delete",

View File

@@ -4,15 +4,12 @@
// there is no lifecycle or Hook in React that we can use to switch // there is no lifecycle or Hook in React that we can use to switch
// .current at the right timing." // .current at the right timing."
// So we will have to make do with this "close enough" approach for now. // So we will have to make do with this "close enough" approach for now.
import { useEffect, useRef } from "react"; import { useLayoutEffect, useRef } from "react";
import { isDebug } from "core";
export const useEvent = <Fn>(fn: Fn | ((...args: any[]) => any) | undefined): Fn => { export const useEvent = <Fn>(fn: Fn): Fn => {
const ref = useRef([fn, (...args) => ref[0](...args)]).current; if (isDebug()) {
// Per Dan Abramov: useInsertionEffect executes marginally closer to the console.warn("useEvent() is deprecated");
// correct timing for ref synchronization than useLayoutEffect on React 18. }
// See: https://github.com/facebook/react/pull/25881#issuecomment-1356244360 return fn;
useEffect(() => {
ref[0] = fn;
}, []);
return ref[1];
}; };

View File

@@ -1,5 +1,6 @@
import { useClickOutside, useHotkeys } from "@mantine/hooks"; import { useClickOutside, useHotkeys } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clampNumber } from "core/utils/numbers";
import { throttle } from "lodash-es"; import { throttle } from "lodash-es";
import { ScrollArea } from "radix-ui"; import { ScrollArea } from "radix-ui";
import { import {
@@ -12,13 +13,20 @@ import {
import type { IconType } from "react-icons"; import type { IconType } from "react-icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { useEvent } from "ui/hooks/use-event";
import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell"; import { AppShellProvider, useAppShell } from "ui/layouts/AppShell/use-appshell";
import { appShellStore } from "ui/store";
import { useLocation } from "wouter";
export function Root({ children }) { export function Root({ children }: { children: React.ReactNode }) {
const sidebarWidth = appShellStore((store) => store.sidebarWidth);
return ( return (
<AppShellProvider> <AppShellProvider>
<div data-shell="root" className="flex flex-1 flex-col select-none h-dvh"> <div
id="app-shell"
data-shell="root"
className="flex flex-1 flex-col select-none h-dvh"
style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
>
{children} {children}
</div> </div>
</AppShellProvider> </AppShellProvider>
@@ -48,7 +56,7 @@ export const NavLink = <E extends React.ElementType = "a">({
<Tag <Tag
{...otherProps} {...otherProps}
className={twMerge( className={twMerge(
"px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link", "px-6 py-2 [&.active]:bg-muted [&.active]:hover:bg-primary/15 hover:bg-primary/5 flex flex-row items-center rounded-full gap-2.5 link transition-colors",
disabled && "opacity-50 cursor-not-allowed", disabled && "opacity-50 cursor-not-allowed",
className, className,
)} )}
@@ -80,7 +88,7 @@ export function Main({ children }) {
data-shell="main" data-shell="main"
className={twMerge( className={twMerge(
"flex flex-col flex-grow w-1 flex-shrink-1", "flex flex-col flex-grow w-1 flex-shrink-1",
sidebar.open && "max-w-[calc(100%-350px)]", sidebar.open && "md:max-w-[calc(100%-var(--sidebar-width))]",
)} )}
> >
{children} {children}
@@ -89,47 +97,38 @@ export function Main({ children }) {
} }
export function Sidebar({ children }) { export function Sidebar({ children }) {
const ctx = useAppShell(); const open = appShellStore((store) => store.sidebarOpen);
const close = appShellStore((store) => store.closeSidebar);
const ref = useClickOutside(close, null, [document.getElementById("header")]);
const [location] = useLocation();
const ref = useClickOutside(ctx.sidebar?.handler?.close); const closeHandler = () => {
open && close();
};
const onClickBackdrop = useEvent((e: React.MouseEvent) => { // listen for window location change
e.preventDefault(); useEffect(closeHandler, [location]);
e.stopPropagation();
ctx?.sidebar?.handler.close();
});
const onEscape = useEvent(() => {
if (ctx?.sidebar?.open) {
ctx?.sidebar?.handler.close();
}
});
// @todo: potentially has to be added to the root, as modals could be opened // @todo: potentially has to be added to the root, as modals could be opened
useHotkeys([["Escape", onEscape]]); useHotkeys([["Escape", closeHandler]]);
if (!ctx) {
console.warn("AppShell.Sidebar: missing AppShellContext");
return null;
}
return ( return (
<> <>
<aside <aside
data-shell="sidebar" data-shell="sidebar"
className="hidden md:flex flex-col basis-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-muted/10" className="hidden md:flex flex-col basis-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full bg-muted/10"
> >
{children} {children}
</aside> </aside>
<SidebarResize />
<div <div
data-open={ctx?.sidebar?.open} data-open={open}
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm" className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm"
onClick={onClickBackdrop}
> >
<aside <aside
/*ref={ref}*/ ref={ref}
data-shell="sidebar" data-shell="sidebar"
className="flex-col w-[350px] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-background" className="flex-col w-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full border-muted border-r bg-background"
> >
{children} {children}
</aside> </aside>
@@ -138,6 +137,59 @@ export function Sidebar({ children }) {
); );
} }
const SidebarResize = () => {
const setSidebarWidth = appShellStore((store) => store.setSidebarWidth);
const [isResizing, setIsResizing] = useState(false);
const [startX, setStartX] = useState(0);
const [startWidth, setStartWidth] = useState(0);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
setStartX(e.clientX);
setStartWidth(
Number.parseInt(
getComputedStyle(document.getElementById("app-shell")!)
.getPropertyValue("--sidebar-width")
.replace("px", ""),
),
);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const diff = e.clientX - startX;
const newWidth = clampNumber(startWidth + diff, 250, window.innerWidth * 0.5);
setSidebarWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, startX, startWidth]);
return (
<div
data-active={isResizing ? 1 : undefined}
className="w-px h-full hidden md:flex bg-muted after:transition-colors relative after:absolute after:inset-0 after:-left-px after:w-[2px] select-none data-[active]:after:bg-sky-400 data-[active]:cursor-col-resize hover:after:bg-sky-400 hover:cursor-col-resize after:z-2"
onMouseDown={handleMouseDown}
style={{ touchAction: "none" }}
/>
);
};
export function SectionHeaderTitle({ children, className, ...props }: ComponentProps<"h2">) { export function SectionHeaderTitle({ children, className, ...props }: ComponentProps<"h2">) {
return ( return (
<h2 <h2

View File

@@ -1,4 +1,3 @@
import { ucFirstAllSnakeToPascalWithSpaces } from "core/utils";
import { useMemo } from "react"; import { useMemo } from "react";
import { TbArrowLeft, TbDots } from "react-icons/tb"; import { TbArrowLeft, TbDots } from "react-icons/tb";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
@@ -7,7 +6,7 @@ import { Dropdown } from "../../components/overlay/Dropdown";
import { useEvent } from "../../hooks/use-event"; import { useEvent } from "../../hooks/use-event";
type Breadcrumb = { type Breadcrumb = {
label: string | JSX.Element; label: string | Element;
onClick?: () => void; onClick?: () => void;
href?: string; href?: string;
}; };

View File

@@ -12,7 +12,6 @@ import {
} from "react-icons/tb"; } from "react-icons/tb";
import { useAuth, useBkndWindowContext } from "ui/client"; import { useAuth, useBkndWindowContext } from "ui/client";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndSystemTheme } from "ui/client/schema/system/use-bknd-system";
import { useTheme } from "ui/client/use-theme"; import { useTheme } from "ui/client/use-theme";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
@@ -20,10 +19,11 @@ import { Logo } from "ui/components/display/Logo";
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { useEvent } from "ui/hooks/use-event"; import { useEvent } from "ui/hooks/use-event";
import { useAppShell } from "ui/layouts/AppShell/use-appshell";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { NavLink } from "./AppShell"; import { NavLink } from "./AppShell";
import { autoFormatString } from "core/utils";
import { appShellStore } from "ui/store";
export function HeaderNavigation() { export function HeaderNavigation() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
@@ -105,19 +105,19 @@ export function HeaderNavigation() {
} }
function SidebarToggler() { function SidebarToggler() {
const { sidebar } = useAppShell(); const toggle = appShellStore((store) => store.toggleSidebar);
return ( const open = appShellStore((store) => store.sidebarOpen);
<IconButton size="lg" Icon={sidebar.open ? TbX : TbMenu2} onClick={sidebar.handler.toggle} /> return <IconButton size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
);
} }
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
const { app } = useBknd(); const { app } = useBknd();
const { theme } = useTheme(); const { theme } = useTheme();
const { logo_return_path = "/" } = app.getAdminConfig(); const { logo_return_path = "/" } = app.options;
return ( return (
<header <header
id="header"
data-shell="header" data-shell="header"
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
> >
@@ -142,7 +142,7 @@ export function Header({ hasSidebar = true }) {
} }
function UserMenu() { function UserMenu() {
const { adminOverride, config } = useBknd(); const { config, options } = useBknd();
const auth = useAuth(); const auth = useAuth();
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { logout_route } = useBkndWindowContext(); const { logout_route } = useBkndWindowContext();
@@ -173,7 +173,7 @@ function UserMenu() {
} }
} }
if (!adminOverride) { if (!options.theme) {
items.push(() => <UserMenuThemeToggler />); items.push(() => <UserMenuThemeToggler />);
} }
@@ -193,17 +193,15 @@ function UserMenu() {
} }
function UserMenuThemeToggler() { function UserMenuThemeToggler() {
const { theme, toggle } = useBkndSystemTheme(); const { value, themes, setTheme } = useTheme();
return ( return (
<div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5"> <div className="flex flex-col items-center mt-1 pt-1 border-t border-primary/5">
<SegmentedControl <SegmentedControl
withItemsBorders={false}
className="w-full" className="w-full"
data={[ data={themes.map((t) => ({ value: t, label: autoFormatString(t) }))}
{ value: "light", label: "Light" }, value={value}
{ value: "dark", label: "Dark" }, onChange={setTheme}
]}
value={theme}
onChange={toggle}
size="xs" size="xs"
/> />
</div> </div>

View File

@@ -107,8 +107,11 @@ export function createMantineTheme(scheme: "light" | "dark"): {
}), }),
}), }),
Tabs: Tabs.extend({ Tabs: Tabs.extend({
classNames: (theme, props) => ({ vars: (theme, props) => ({
tab: "data-[active=true]:border-primary", // https://mantine.dev/styles/styles-api/
root: {
"--tabs-color": "border-primary",
},
}), }),
}), }),
Menu: Menu.extend({ Menu: Menu.extend({

View File

@@ -49,8 +49,7 @@ export function withQuery(url: string, query: object) {
export function withAbsolute(url: string) { export function withAbsolute(url: string) {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; return app.getAbsolutePath(url);
return `~/${basepath}/${url}`.replace(/\/+/g, "/");
} }
export function useRouteNavigate() { export function useRouteNavigate() {
@@ -65,7 +64,7 @@ export function useNavigate() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const router = useRouter(); const router = useRouter();
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath; const basepath = app.options.basepath;
return [ return [
( (
url: string, url: string,
@@ -121,7 +120,6 @@ export function useGoBack(
}, },
) { ) {
const { app } = useBknd(); const { app } = useBknd();
const basepath = app.getAdminConfig().basepath;
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const referrer = document.referrer; const referrer = document.referrer;
const history_length = window.history.length; const history_length = window.history.length;
@@ -142,9 +140,7 @@ export function useGoBack(
} else { } else {
//console.log("used fallback"); //console.log("used fallback");
if (typeof fallback === "string") { if (typeof fallback === "string") {
const _fallback = options?.absolute const _fallback = options?.absolute ? app.getAbsolutePath(fallback) : fallback;
? `~/${basepath}${fallback}`.replace(/\/+/g, "/")
: fallback;
//console.log("fallback", _fallback); //console.log("fallback", _fallback);
if (options?.native) { if (options?.native) {

View File

@@ -1,30 +1,31 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities;
#bknd-admin.dark, @custom-variant dark (&:where(.dark, .dark *));
.dark .bknd-admin,
.bknd-admin.dark {
--color-primary: 250 250 250; /* zinc-50 */
--color-background: 30 31 34;
--color-muted: 47 47 52;
--color-darkest: 255 255 255; /* white */
--color-lightest: 24 24 27; /* black */
}
#bknd-admin, #bknd-admin,
.bknd-admin { .bknd-admin {
--color-primary: 9 9 11; /* zinc-950 */ --color-primary: var(--color-zinc-950);
--color-background: 250 250 250; /* zinc-50 */ --color-background: var(--color-zinc-50);
--color-muted: 228 228 231; /* ? */ --color-muted: var(--color-zinc-200);
--color-darkest: 0 0 0; /* black */ --color-darkest: var(--color-black);
--color-lightest: 255 255 255; /* white */ --color-lightest: var(--color-white);
--color-warning: var(--color-amber-100);
--color-warning-foreground: var(--color-amber-800);
--color-error: var(--color-red-100);
--color-error-foreground: var(--color-red-800);
--color-success: var(--color-green-100);
--color-success-foreground: var(--color-green-800);
--color-info: var(--color-blue-100);
--color-info-foreground: var(--color-blue-800);
--color-resize: var(--color-blue-300);
@mixin light { @mixin light {
--mantine-color-body: rgb(250 250 250); --mantine-color-body: var(--color-zinc-50);
} }
@mixin dark { @mixin dark {
--mantine-color-body: rgb(9 9 11); --mantine-color-body: var(--color-zinc-950);
} }
table { table {
@@ -32,6 +33,43 @@
} }
} }
.dark,
.dark .bknd-admin /* currently used for elements, drop after making headless */,
#bknd-admin.dark,
.bknd-admin.dark {
--color-primary: var(--color-zinc-50);
--color-background: rgb(30 31 34);
--color-muted: rgb(47 47 52);
--color-darkest: var(--color-white);
--color-lightest: rgb(24 24 27);
--color-warning: var(--color-yellow-900);
--color-warning-foreground: var(--color-yellow-200);
--color-error: var(--color-red-950);
--color-error-foreground: var(--color-red-200);
--color-success: var(--color-green-950);
--color-success-foreground: var(--color-green-200);
--color-info: var(--color-blue-950);
--color-info-foreground: var(--color-blue-200);
}
@theme {
--color-primary: var(--color-primary);
--color-background: var(--color-background);
--color-muted: var(--color-muted);
--color-darkest: var(--color-darkest);
--color-lightest: var(--color-lightest);
--color-warning: var(--color-warning);
--color-warning-foreground: var(--color-warning-foreground);
--color-error: var(--color-error);
--color-error-foreground: var(--color-error-foreground);
--color-success: var(--color-success);
--color-success-foreground: var(--color-success-foreground);
--color-info: var(--color-info);
--color-info-foreground: var(--color-info-foreground);
}
#bknd-admin { #bknd-admin {
@apply bg-background text-primary overflow-hidden h-dvh w-dvw; @apply bg-background text-primary overflow-hidden h-dvh w-dvw;
@@ -51,37 +89,12 @@ body,
@apply flex flex-1 flex-col h-dvh w-dvw; @apply flex flex-1 flex-col h-dvh w-dvw;
} }
@layer components { .link {
.link { @apply active:translate-y-px;
@apply transition-colors active:translate-y-px; }
}
.img-responsive { .img-responsive {
@apply max-h-full w-auto; @apply max-h-full w-auto;
}
/**
* debug classes
*/
.bordered-red {
@apply border-2 border-red-500;
}
.bordered-green {
@apply border-2 border-green-500;
}
.bordered-blue {
@apply border-2 border-blue-500;
}
.bordered-violet {
@apply border-2 border-violet-500;
}
.bordered-yellow {
@apply border-2 border-yellow-500;
}
} }
#bknd-admin, #bknd-admin,

Some files were not shown because too many files have changed in this diff Show More