mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 21:06:04 +00:00
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});*/
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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()", () => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
107
app/__test__/data/specs/connection/SqliteIntrospector.spec.ts
Normal file
107
app/__test__/data/specs/connection/SqliteIntrospector.spec.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
709
app/__test__/modules/migrations/samples/v8-2.json
Normal file
709
app/__test__/modules/migrations/samples/v8-2.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
598
app/__test__/modules/migrations/samples/v8.json
Normal file
598
app/__test__/modules/migrations/samples/v8.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
//import type { BkndConfig } from "./src";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
app: {
|
|
||||||
connection: {
|
|
||||||
type: "libsql",
|
|
||||||
config: {
|
|
||||||
//url: "http://localhost:8080"
|
|
||||||
url: ":memory:"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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()]);
|
|
||||||
}
|
|
||||||
21
app/build.ts
21
app/build.ts
@@ -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"));
|
||||||
|
|||||||
106
app/package.json
106
app/package.json
@@ -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,6 +227,7 @@
|
|||||||
"cloudflare",
|
"cloudflare",
|
||||||
"nextjs",
|
"nextjs",
|
||||||
"remix",
|
"remix",
|
||||||
|
"react-router",
|
||||||
"astro",
|
"astro",
|
||||||
"bun",
|
"bun",
|
||||||
"node"
|
"node"
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
1
app/src/adapter/react-router/index.ts
Normal file
1
app/src/adapter/react-router/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./react-router.adapter";
|
||||||
@@ -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);
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./remix.adapter";
|
|
||||||
export * from "./AdminPage";
|
|
||||||
@@ -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."));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
79
app/src/cli/utils/telemetry.ts
Normal file
79
app/src/cli/utils/telemetry.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
app/src/data/connection/BaseIntrospector.ts
Normal file
75
app/src/data/connection/BaseIntrospector.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
app/src/data/connection/index.ts
Normal file
14
app/src/data/connection/index.ts
Normal 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";
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
46
app/src/data/connection/sqlite/SqliteConnection.ts
Normal file
46
app/src/data/connection/sqlite/SqliteConnection.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/src/data/connection/sqlite/SqliteIntrospector.ts
Normal file
95
app/src/data/connection/sqlite/SqliteIntrospector.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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: [] };
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export class VirtualField extends Field<VirtualFieldConfig> {
|
|||||||
return virtualFieldConfigSchema;
|
return virtualFieldConfigSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
schema() {
|
override schema() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export class MediaField<
|
|||||||
return this.config.min_items;
|
return this.config.min_items;
|
||||||
}
|
}
|
||||||
|
|
||||||
schema() {
|
override schema() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|
||||||
// we can safely build modules, since config version is up to date
|
|
||||||
// it's up to date because we use default configs (no fetch result)
|
|
||||||
this._version = CURRENT_VERSION;
|
|
||||||
await this.syncConfigTable();
|
await this.syncConfigTable();
|
||||||
const state = await this.buildModules();
|
|
||||||
if (!state.saved) {
|
|
||||||
await this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
// run initial setup
|
const version_before = this.version();
|
||||||
await this.setupInitial();
|
const [_version, _configs] = await migrate(version_before, result.json, {
|
||||||
|
db: this.db,
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.clear();
|
this._version = _version;
|
||||||
return this;
|
this.ctx().flags.sync_required = true;
|
||||||
}
|
|
||||||
this.logger.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrate to latest if needed
|
this.logger.log("migrated to", _version);
|
||||||
this.logger.log("check migrate");
|
$console.log("Migrated config from", version_before, "to", this.version());
|
||||||
const migration = await this.migrate();
|
|
||||||
if (migration.success && migration.migrated) {
|
this.createModules(_configs);
|
||||||
this.logger.log("skipping build after migration");
|
await this.buildModules();
|
||||||
} else {
|
} else {
|
||||||
this.logger.log("trigger build modules");
|
this.logger.log("version is current", this.version());
|
||||||
|
this.createModules(result.json);
|
||||||
|
await this.buildModules();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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>(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
|
||||||
color: "rgb(9,9,11)",
|
|
||||||
backgroundColor: "rgb(250,250,250)",
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
color: "rgb(250,250,250)",
|
|
||||||
backgroundColor: "rgb(30,31,34)",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
@media (prefers-color-scheme: dark) {
|
||||||
...base,
|
color: rgb(250,250,250);
|
||||||
...styles[theme === "light" ? "light" : "dark"],
|
background-color: rgb(30,31,34);
|
||||||
};
|
}
|
||||||
};
|
`;
|
||||||
|
|
||||||
|
const loaderStyle = css`
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
`;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,20 +101,15 @@ export function BkndProvider({
|
|||||||
fallback: true,
|
fallback: true,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
if (adminOverride) {
|
|
||||||
newSchema.config.server.admin = {
|
|
||||||
...newSchema.config.server.admin,
|
|
||||||
...adminOverride,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
|
document.startViewTransition(() => {
|
||||||
setSchema(newSchema);
|
setSchema(newSchema);
|
||||||
setWithSecrets(_includeSecrets);
|
setWithSecrets(_includeSecrets);
|
||||||
setFetched(true);
|
setFetched(true);
|
||||||
set_local_version((v) => v + 1);
|
set_local_version((v) => v + 1);
|
||||||
fetching.current = Fetching.None;
|
fetching.current = Fetching.None;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireSecrets() {
|
async function requireSecrets() {
|
||||||
@@ -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: "/",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
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) => {
|
||||||
|
let api: Api;
|
||||||
|
|
||||||
|
if (props && "api" in props) {
|
||||||
|
api = props.api;
|
||||||
|
} else {
|
||||||
const winCtx = useBkndWindowContext();
|
const winCtx = useBkndWindowContext();
|
||||||
const _ctx_baseUrl = useBaseUrl();
|
const _ctx_baseUrl = useBaseUrl();
|
||||||
|
const { baseUrl, user } = props;
|
||||||
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -33,7 +41,8 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
//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__) {
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { BkndProvider, useBknd } from "./BkndProvider";
|
export { BkndProvider, type BkndAdminOptions, useBknd } from "./BkndProvider";
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 : {}),
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,39 +89,14 @@ 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 transition-colors active:translate-y-px;
|
@apply 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,
|
||||||
.bknd-admin {
|
.bknd-admin {
|
||||||
/* Chrome, Edge, and Safari */
|
/* Chrome, Edge, and Safari */
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user