mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06d7558c3c |
Binary file not shown.
@@ -1 +0,0 @@
|
||||
hello
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
|
||||
import { getDummyConnection } from "../../__test__/helper";
|
||||
import { ModuleManager } from "modules/ModuleManager";
|
||||
import { em, entity, text } from "data/prototype";
|
||||
|
||||
async function createApp(config: CreateAppConfig = {}) {
|
||||
const app = internalCreateApp({
|
||||
connection: getDummyConnection().dummyConnection,
|
||||
...config,
|
||||
options: {
|
||||
...config.options,
|
||||
mode: "code",
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("code-only", () => {
|
||||
test("should create app with correct manager", async () => {
|
||||
const app = await createApp();
|
||||
await app.build();
|
||||
|
||||
expect(app.version()).toBeDefined();
|
||||
expect(app.modules).toBeInstanceOf(ModuleManager);
|
||||
});
|
||||
|
||||
test("should not perform database syncs", async () => {
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
});
|
||||
expect(app.em.entities.map((e) => e.name)).toEqual(["test"]);
|
||||
expect(
|
||||
await app.em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute(),
|
||||
).toEqual([]);
|
||||
|
||||
// only perform when explicitly forced
|
||||
await app.em.schema().sync({ force: true });
|
||||
expect(
|
||||
await app.em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute()
|
||||
.then((r) => r.map((r) => r.name)),
|
||||
).toEqual(["test", "sqlite_sequence"]);
|
||||
});
|
||||
|
||||
test("should not perform seeding", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx) => {
|
||||
called();
|
||||
await ctx.em.mutator("test").insertOne({ name: "test" });
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.em.schema().sync({ force: true });
|
||||
expect(called).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await app.em
|
||||
.repo("test")
|
||||
.findMany({})
|
||||
.then((r) => r.data),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("should sync and perform seeding", async () => {
|
||||
const called = mock(() => null);
|
||||
const app = await createApp({
|
||||
config: {
|
||||
data: em({
|
||||
test: entity("test", {
|
||||
name: text(),
|
||||
}),
|
||||
}).toJSON(),
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx) => {
|
||||
called();
|
||||
await ctx.em.mutator("test").insertOne({ name: "test" });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.em.schema().sync({ force: true });
|
||||
await app.options?.seed?.({
|
||||
...app.modules.ctx(),
|
||||
app: app,
|
||||
});
|
||||
expect(called).toHaveBeenCalled();
|
||||
expect(
|
||||
await app.em
|
||||
.repo("test")
|
||||
.findMany({})
|
||||
.then((r) => r.data),
|
||||
).toEqual([{ id: 1, name: "test" }]);
|
||||
});
|
||||
|
||||
test("should not allow to modify config", async () => {
|
||||
const app = await createApp();
|
||||
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
|
||||
expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false);
|
||||
expect(() => {
|
||||
app.modules.configs().auth.enabled = true;
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { afterAll, describe, expect, test } from "bun:test";
|
||||
import { afterAll, describe, expect, spyOn, test } from "bun:test";
|
||||
import { randomString } from "core/utils";
|
||||
import { Entity, EntityManager } from "data/entities";
|
||||
import { TextField, EntityIndex } from "data/fields";
|
||||
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
|
||||
const diffAfter = await em.schema().getDiff();
|
||||
expect(diffAfter.length).toBe(0);
|
||||
});
|
||||
|
||||
test("returns statements", async () => {
|
||||
const amount = 5;
|
||||
const entities = new Array(amount)
|
||||
.fill(0)
|
||||
.map(() => new Entity(randomString(16), [new TextField("text")]));
|
||||
const em = new EntityManager(entities, dummyConnection);
|
||||
const statements = await em.schema().sync({ force: true });
|
||||
expect(statements.length).toBe(amount);
|
||||
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("batches statements", async () => {
|
||||
const { dummyConnection } = getDummyConnection();
|
||||
const entities = new Array(20)
|
||||
.fill(0)
|
||||
.map(() => new Entity(randomString(16), [new TextField("text")]));
|
||||
const em = new EntityManager(entities, dummyConnection);
|
||||
const spy = spyOn(em.connection, "executeQueries");
|
||||
const statements = await em.schema().sync();
|
||||
expect(statements.length).toBe(entities.length);
|
||||
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
|
||||
true,
|
||||
);
|
||||
await em.schema().sync({ force: true });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const tables = await em.connection.kysely
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "=", "table")
|
||||
.selectAll()
|
||||
.execute();
|
||||
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||
|
||||
beforeAll(() => {
|
||||
//disableConsoleLog();
|
||||
disableConsoleLog();
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
});
|
||||
afterAll(enableConsoleLog);
|
||||
@@ -94,38 +94,4 @@ describe("MediaController", () => {
|
||||
expect(res.status).toBe(413);
|
||||
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
||||
});
|
||||
|
||||
test("audio files", async () => {
|
||||
const app = await makeApp();
|
||||
const file = Bun.file(`${assetsPath}/test.mp3`);
|
||||
const name = makeName("mp3");
|
||||
const res = await app.server.request("/api/media/upload/" + name, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
const result = (await res.json()) as any;
|
||||
expect(result.data.mime_type).toStartWith("audio/mpeg");
|
||||
expect(result.name).toBe(name);
|
||||
|
||||
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
await destFile.delete();
|
||||
});
|
||||
|
||||
test("text files", async () => {
|
||||
const app = await makeApp();
|
||||
const file = Bun.file(`${assetsPath}/test.txt`);
|
||||
const name = makeName("txt");
|
||||
const res = await app.server.request("/api/media/upload/" + name, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
});
|
||||
const result = (await res.json()) as any;
|
||||
expect(result.data.mime_type).toStartWith("text/plain");
|
||||
expect(result.name).toBe(name);
|
||||
|
||||
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||
expect(destFile.exists()).resolves.toBe(true);
|
||||
await destFile.delete();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,8 +71,6 @@ describe("media/mime-types", () => {
|
||||
["application/zip", "zip"],
|
||||
["text/tab-separated-values", "tsv"],
|
||||
["application/zip", "zip"],
|
||||
["application/pdf", "pdf"],
|
||||
["audio/mpeg", "mp3"],
|
||||
] as const;
|
||||
|
||||
for (const [mime, ext] of tests) {
|
||||
@@ -90,9 +88,6 @@ describe("media/mime-types", () => {
|
||||
["image.jpeg", "jpeg"],
|
||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||
["file.pdf", "pdf"],
|
||||
["file.mp3", "mp3"],
|
||||
["robots.txt", "txt"],
|
||||
] as const;
|
||||
|
||||
for (const [filename, ext] of tests) {
|
||||
@@ -107,36 +102,4 @@ describe("media/mime-types", () => {
|
||||
const [, ext] = getRandomizedFilename(file).split(".");
|
||||
expect(ext).toBe("jpg");
|
||||
});
|
||||
|
||||
test("getRandomizedFilename with body", async () => {
|
||||
// should keep "pdf"
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.pdf", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("pdf");
|
||||
|
||||
{
|
||||
// no ext, should use "pdf" only for known formats
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("pdf");
|
||||
}
|
||||
|
||||
{
|
||||
// wrong ext, should keep the wrong one
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.what", { type: "application/pdf" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("what");
|
||||
}
|
||||
|
||||
{
|
||||
// txt
|
||||
const [, ext] = getRandomizedFilename(
|
||||
new File([""], "file.txt", { type: "text/plain" }),
|
||||
).split(".");
|
||||
expect(ext).toBe("txt");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { formatNumber } from "bknd/utils";
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const deps = Object.keys(pkg.dependencies);
|
||||
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||
const external = ["jsonv-ts/*", "wrangler", ...deps];
|
||||
|
||||
if (process.env.DEBUG) {
|
||||
const result = await esbuild.build({
|
||||
|
||||
@@ -272,7 +272,6 @@ async function buildAdapters() {
|
||||
),
|
||||
tsup.build(
|
||||
baseConfig("cloudflare/proxy", {
|
||||
target: "esnext",
|
||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||
outDir: "dist/adapter/cloudflare",
|
||||
metafile: false,
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.18.1",
|
||||
"version": "0.18.0-rc.7",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -40,7 +40,7 @@
|
||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||
"test:vitest:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:adapters": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts",
|
||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
|
||||
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
|
||||
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||
@@ -65,7 +65,7 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "0.8.5",
|
||||
"jsonv-ts": "0.8.4",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||
import { bunTestRunner } from "adapter/bun/test";
|
||||
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
||||
|
||||
/* beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog); */
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
describe("cf adapter", () => {
|
||||
const DB_URL = ":memory:";
|
||||
|
||||
@@ -16,7 +16,7 @@ export {
|
||||
type GetBindingType,
|
||||
type BindingMap,
|
||||
} from "./bindings";
|
||||
export { constants, makeConfig, type CloudflareContext } from "./config";
|
||||
export { constants, type CloudflareContext } from "./config";
|
||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||
export { registries } from "bknd";
|
||||
export { devFsVitePlugin, devFsWrite } from "./vite";
|
||||
|
||||
@@ -18,29 +18,6 @@ export type WithPlatformProxyOptions = {
|
||||
proxyOptions?: GetPlatformProxyOptions;
|
||||
};
|
||||
|
||||
async function getPlatformProxy(opts?: GetPlatformProxyOptions) {
|
||||
try {
|
||||
const { version } = await import("wrangler/package.json", { with: { type: "json" } }).then(
|
||||
(pkg) => pkg.default,
|
||||
);
|
||||
$console.log("Using wrangler version", version);
|
||||
const { getPlatformProxy } = await import("wrangler");
|
||||
return getPlatformProxy(opts);
|
||||
} catch (e) {
|
||||
$console.error("Failed to import wrangler", String(e));
|
||||
const resolved = import.meta.resolve("wrangler");
|
||||
$console.log("Wrangler resolved to", resolved);
|
||||
const file = resolved?.split("/").pop();
|
||||
if (file?.endsWith(".json")) {
|
||||
$console.error(
|
||||
"You have a `wrangler.json` in your current directory. Please change to .jsonc or .toml",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env> = {},
|
||||
opts?: WithPlatformProxyOptions,
|
||||
@@ -54,6 +31,7 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||
async function getEnv(env?: Env): Promise<Env> {
|
||||
if (use_proxy) {
|
||||
if (!proxy) {
|
||||
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
|
||||
proxy = await getPlatformProxy(opts?.proxyOptions);
|
||||
process.on("exit", () => {
|
||||
proxy?.dispose();
|
||||
|
||||
@@ -28,8 +28,7 @@ describe("StorageR2Adapter", async () => {
|
||||
const buffer = readFileSync(path.join(basePath, "image.png"));
|
||||
const file = new File([buffer], "image.png", { type: "image/png" });
|
||||
|
||||
// miniflare doesn't support range requests
|
||||
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
|
||||
await adapterTestSuite(viTestRunner, adapter, file);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -80,79 +80,18 @@ export class StorageLocalAdapter extends StorageAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
private parseRangeHeader(
|
||||
rangeHeader: string,
|
||||
fileSize: number,
|
||||
): { start: number; end: number } | null {
|
||||
// Parse "bytes=start-end" format
|
||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, startStr, endStr] = match;
|
||||
let start = startStr ? Number.parseInt(startStr, 10) : 0;
|
||||
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
|
||||
|
||||
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
|
||||
if (!startStr && endStr) {
|
||||
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
|
||||
end = fileSize - 1;
|
||||
}
|
||||
|
||||
// Validate range
|
||||
if (start < 0 || end >= fileSize || start > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
try {
|
||||
const filePath = `${this.config.path}/${key}`;
|
||||
const stats = await stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const content = await readFile(`${this.config.path}/${key}`);
|
||||
const mimeType = guessMimeType(key);
|
||||
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const rangeHeader = headers.get("range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const range = this.parseRangeHeader(rangeHeader, fileSize);
|
||||
|
||||
if (!range) {
|
||||
// Invalid range - return 416 Range Not Satisfiable
|
||||
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
|
||||
return new Response("", {
|
||||
status: 416,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
const content = await readFile(filePath, { encoding: null });
|
||||
const chunk = content.slice(start, end + 1);
|
||||
|
||||
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||
responseHeaders.set("Content-Length", chunk.length.toString());
|
||||
|
||||
return new Response(chunk, {
|
||||
status: 206, // Partial Content
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} else {
|
||||
// Normal request - return entire file
|
||||
const content = await readFile(filePath);
|
||||
responseHeaders.set("Content-Length", content.length.toString());
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: responseHeaders,
|
||||
headers: {
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
"Content-Length": content.length.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle file reading errors
|
||||
return new Response("", { status: 404 });
|
||||
|
||||
@@ -76,9 +76,6 @@ export async function getConfigPath(filePath?: string) {
|
||||
const config_path = path.resolve(process.cwd(), filePath);
|
||||
if (await fileExists(config_path)) {
|
||||
return config_path;
|
||||
} else {
|
||||
$console.error(`Config file could not be resolved: ${config_path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { Config } from "@libsql/client/node";
|
||||
import { StorageLocalAdapter } from "adapter/node/storage";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd";
|
||||
import { config, type App, type CreateAppConfig, type MaybePromise } from "bknd";
|
||||
import dotenv from "dotenv";
|
||||
import { registries } from "modules/registries";
|
||||
import c from "picocolors";
|
||||
import path from "node:path";
|
||||
import {
|
||||
|
||||
@@ -8,7 +8,6 @@ export const sync: CliCommand = (program) => {
|
||||
withConfigOptions(program.command("sync"))
|
||||
.description("sync database")
|
||||
.option("--force", "perform database syncing operations")
|
||||
.option("--seed", "perform seeding operations")
|
||||
.option("--drop", "include destructive DDL operations")
|
||||
.option("--out <file>", "output file")
|
||||
.option("--sql", "use sql output")
|
||||
@@ -30,22 +29,8 @@ export const sync: CliCommand = (program) => {
|
||||
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
|
||||
await schema.sync({ force: true, drop: options.drop });
|
||||
|
||||
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||
console.info(`\n${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||
console.info(`${c.green("Database synced")}`);
|
||||
|
||||
if (options.seed) {
|
||||
console.info(c.dim("\nExecuting seed..."));
|
||||
const seed = app.options?.seed;
|
||||
if (seed) {
|
||||
await app.options?.seed?.({
|
||||
...app.modules.ctx(),
|
||||
app: app,
|
||||
});
|
||||
console.info(c.green("Seed executed"));
|
||||
} else {
|
||||
console.info(c.yellow("No seed function provided"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (options.out) {
|
||||
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getVersion } from "./utils/sys";
|
||||
import { capture, flush, init } from "cli/utils/telemetry";
|
||||
const program = new Command();
|
||||
|
||||
async function main() {
|
||||
export async function main() {
|
||||
await init();
|
||||
capture("start");
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ export class SqliteIntrospector extends BaseIntrospector {
|
||||
)) FROM pragma_index_list(m.name) i
|
||||
LEFT JOIN sqlite_master im ON im.name = i.name
|
||||
AND im.type = 'index'
|
||||
WHERE i.name not like 'sqlite_%'
|
||||
) AS indices
|
||||
FROM sqlite_master m
|
||||
WHERE m.type IN ('table', 'view')
|
||||
|
||||
@@ -34,7 +34,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
private _entities: Entity[] = [];
|
||||
private _relations: EntityRelation[] = [];
|
||||
private _indices: EntityIndex[] = [];
|
||||
private _schema?: SchemaManager;
|
||||
readonly emgr: EventManager<typeof EntityManager.Events>;
|
||||
static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
|
||||
|
||||
@@ -249,11 +248,7 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
||||
}
|
||||
|
||||
schema() {
|
||||
if (!this._schema) {
|
||||
this._schema = new SchemaManager(this);
|
||||
}
|
||||
|
||||
return this._schema;
|
||||
return new SchemaManager(this);
|
||||
}
|
||||
|
||||
// @todo: centralize and add tests
|
||||
|
||||
@@ -21,34 +21,4 @@ describe("EntityTypescript", () => {
|
||||
const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection()));
|
||||
expect(et.toString()).toContain('users?: DB["users"];');
|
||||
});
|
||||
|
||||
it("should generate correct typescript for system entities with uuid primary field", () => {
|
||||
const schema = proto.em(
|
||||
{
|
||||
test: proto.entity(
|
||||
"test",
|
||||
{
|
||||
name: proto.text(),
|
||||
},
|
||||
{
|
||||
primary_format: "uuid",
|
||||
},
|
||||
),
|
||||
users: proto.systemEntity(
|
||||
"users",
|
||||
{
|
||||
name: proto.text(),
|
||||
},
|
||||
{
|
||||
primary_format: "uuid",
|
||||
},
|
||||
),
|
||||
},
|
||||
({ relation }, { test, users }) => {
|
||||
relation(test).manyToOne(users);
|
||||
},
|
||||
);
|
||||
const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection()));
|
||||
expect(et.toString()).toContain("users_id?: string;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { type RelationType, RelationTypes } from "./relation-types";
|
||||
|
||||
/**
|
||||
* Both source and target receive a mapping field
|
||||
* Source gets the mapping field, and can $create the target
|
||||
* @todo: determine if it should be removed
|
||||
*/
|
||||
export type OneToOneRelationConfig = ManyToOneRelationConfig;
|
||||
@@ -18,7 +17,6 @@ export class OneToOneRelation extends ManyToOneRelation {
|
||||
inversedBy,
|
||||
sourceCardinality: 1,
|
||||
required,
|
||||
with_limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,8 +102,8 @@ export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelati
|
||||
return new TextField("reference", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
getEntityIdField(): TextField {
|
||||
return new TextField("entity_id", { hidden: true, fillable: ["create"] });
|
||||
getEntityIdField(): NumberField {
|
||||
return new NumberField("entity_id", { hidden: true, fillable: ["create"] });
|
||||
}
|
||||
|
||||
initialize(em: EntityManager<any>) {
|
||||
|
||||
@@ -84,10 +84,9 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
}
|
||||
|
||||
override toType(): TFieldTSType {
|
||||
const type = this.config.target_field_type === "integer" ? "number" : "string";
|
||||
return {
|
||||
...super.toType(),
|
||||
type,
|
||||
type: "number",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export class SchemaManager {
|
||||
if (SchemaManager.EXCLUDE_TABLES.includes(table.name)) {
|
||||
continue;
|
||||
}
|
||||
if (!table.name) continue;
|
||||
|
||||
cleanTables.push({
|
||||
...table,
|
||||
@@ -248,20 +247,16 @@ export class SchemaManager {
|
||||
|
||||
async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) {
|
||||
const diff = await this.getDiff();
|
||||
let updates: number = 0;
|
||||
const statements: { sql: string; parameters: readonly unknown[] }[] = [];
|
||||
const schema = this.em.connection.kysely.schema;
|
||||
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
|
||||
|
||||
for (const table of diff) {
|
||||
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
|
||||
let local_updates: number = 0;
|
||||
const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add);
|
||||
const dropFields = table.columns.drop;
|
||||
const dropIndices = table.indices.drop;
|
||||
|
||||
if (table.isDrop) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
if (config.drop) {
|
||||
qbs.push(schema.dropTable(table.name));
|
||||
}
|
||||
@@ -269,8 +264,6 @@ export class SchemaManager {
|
||||
let createQb = schema.createTable(table.name);
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
createQb = createQb.addColumn(...fieldSchema);
|
||||
}
|
||||
@@ -281,8 +274,6 @@ export class SchemaManager {
|
||||
if (addFieldSchemas.length > 0) {
|
||||
// add fields
|
||||
for (const fieldSchema of addFieldSchemas) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
// @ts-ignore
|
||||
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
|
||||
}
|
||||
@@ -292,8 +283,6 @@ export class SchemaManager {
|
||||
if (config.drop && dropFields.length > 0) {
|
||||
// drop fields
|
||||
for (const column of dropFields) {
|
||||
updates++;
|
||||
local_updates++;
|
||||
qbs.push(schema.alterTable(table.name).dropColumn(column));
|
||||
}
|
||||
}
|
||||
@@ -311,35 +300,33 @@ export class SchemaManager {
|
||||
qb = qb.unique();
|
||||
}
|
||||
qbs.push(qb);
|
||||
local_updates++;
|
||||
updates++;
|
||||
}
|
||||
|
||||
// drop indices
|
||||
if (config.drop) {
|
||||
for (const index of dropIndices) {
|
||||
qbs.push(schema.dropIndex(index));
|
||||
local_updates++;
|
||||
updates++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (local_updates === 0) continue;
|
||||
|
||||
// iterate through built qbs
|
||||
// @todo: run in batches
|
||||
for (const qb of qbs) {
|
||||
if (qbs.length > 0) {
|
||||
statements.push(
|
||||
...qbs.map((qb) => {
|
||||
const { sql, parameters } = qb.compile();
|
||||
statements.push({ sql, parameters });
|
||||
return { sql, parameters };
|
||||
}),
|
||||
);
|
||||
|
||||
$console.debug(
|
||||
"[SchemaManager]",
|
||||
`${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`,
|
||||
);
|
||||
|
||||
if (config.force) {
|
||||
try {
|
||||
$console.debug("[SchemaManager]", sql);
|
||||
await qb.execute();
|
||||
await this.em.connection.executeQueries(...qbs);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to execute batch: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { MediaController } from "./api/MediaController";
|
||||
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema";
|
||||
import { mediaFields } from "./media-entities";
|
||||
import * as MediaPermissions from "media/media-permissions";
|
||||
import * as DatabaseEvents from "data/events";
|
||||
|
||||
export type MediaFields = typeof AppMedia.mediaFields;
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
@@ -140,30 +139,6 @@ export class AppMedia extends Module<Required<TAppMediaConfig>> {
|
||||
},
|
||||
{ mode: "sync", id: "delete-data-media" },
|
||||
);
|
||||
|
||||
emgr.onEvent(
|
||||
DatabaseEvents.MutatorDeleteAfter,
|
||||
async (e) => {
|
||||
const { entity, data } = e.params;
|
||||
const fields = entity.fields.filter((f) => f.type === "media");
|
||||
if (fields.length > 0) {
|
||||
const references = fields.map((f) => `${entity.name}.${f.name}`);
|
||||
$console.log("App:storage:file cleaning up", {
|
||||
reference: { $in: references },
|
||||
entity_id: String(data.id),
|
||||
});
|
||||
const { data: deleted } = await em.mutator(media).deleteWhere({
|
||||
reference: { $in: references },
|
||||
entity_id: String(data.id),
|
||||
});
|
||||
for (const file of deleted) {
|
||||
await this.storage.deleteFile(file.path);
|
||||
}
|
||||
$console.log("App:storage:file cleaned up files:", deleted.length);
|
||||
}
|
||||
},
|
||||
{ mode: "async", id: "delete-data-media-after" },
|
||||
);
|
||||
}
|
||||
|
||||
override getOverwritePaths() {
|
||||
|
||||
@@ -43,10 +43,6 @@ export class MediaField<
|
||||
return this.config.max_items;
|
||||
}
|
||||
|
||||
getAllowedMimeTypes(): string[] | undefined {
|
||||
return this.config.mime_types;
|
||||
}
|
||||
|
||||
getMinItems(): number | undefined {
|
||||
return this.config.min_items;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import type { FileListObject } from "media/storage/Storage";
|
||||
import {
|
||||
type BaseModuleApiOptions,
|
||||
type FetchPromise,
|
||||
type ResponseObject,
|
||||
ModuleApi,
|
||||
type PrimaryFieldType,
|
||||
type TInput,
|
||||
} from "modules/ModuleApi";
|
||||
import type { ApiFetcher } from "Api";
|
||||
import type { DB, FileUploadedEventData } from "bknd";
|
||||
|
||||
export type MediaApiOptions = BaseModuleApiOptions & {
|
||||
upload_fetcher: ApiFetcher;
|
||||
@@ -70,14 +67,14 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
return new Headers();
|
||||
}
|
||||
|
||||
protected uploadFile<T extends FileUploadedEventData>(
|
||||
protected uploadFile(
|
||||
body: File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
|
||||
opts?: {
|
||||
filename?: string;
|
||||
path?: TInput;
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
},
|
||||
): FetchPromise<ResponseObject<T>> {
|
||||
) {
|
||||
const headers = {
|
||||
"Content-Type": "application/octet-stream",
|
||||
...(opts?._init?.headers || {}),
|
||||
@@ -109,10 +106,10 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
throw new Error("Invalid filename");
|
||||
}
|
||||
|
||||
return this.post<T>(opts?.path ?? ["upload", name], body, init);
|
||||
return this.post(opts?.path ?? ["upload", name], body, init);
|
||||
}
|
||||
|
||||
async upload<T extends FileUploadedEventData>(
|
||||
async upload(
|
||||
item: Request | Response | string | File | Blob | ReadableStream | Buffer<ArrayBufferLike>,
|
||||
opts: {
|
||||
filename?: string;
|
||||
@@ -127,12 +124,12 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error("Failed to fetch file");
|
||||
}
|
||||
return this.uploadFile<T>(res.body, opts);
|
||||
return this.uploadFile(res.body, opts);
|
||||
} else if (item instanceof Response) {
|
||||
if (!item.body) {
|
||||
throw new Error("Invalid response");
|
||||
}
|
||||
return this.uploadFile<T>(item.body, {
|
||||
return this.uploadFile(item.body, {
|
||||
...(opts ?? {}),
|
||||
_init: {
|
||||
...(opts._init ?? {}),
|
||||
@@ -144,7 +141,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
});
|
||||
}
|
||||
|
||||
return this.uploadFile<T>(item, opts);
|
||||
return this.uploadFile(item, opts);
|
||||
}
|
||||
|
||||
async uploadToEntity(
|
||||
@@ -156,7 +153,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
_init?: Omit<RequestInit, "body">;
|
||||
fetcher?: typeof fetch;
|
||||
},
|
||||
): Promise<ResponseObject<FileUploadedEventData & { result: DB["media"] }>> {
|
||||
) {
|
||||
return this.upload(item, {
|
||||
...opts,
|
||||
path: ["entity", entity, id, field],
|
||||
|
||||
@@ -71,29 +71,22 @@ export class Storage implements EmitsEvents {
|
||||
|
||||
let info: FileUploadPayload = {
|
||||
name,
|
||||
meta: isFile(file)
|
||||
? {
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
}
|
||||
: {
|
||||
meta: {
|
||||
size: 0,
|
||||
type: "application/octet-stream",
|
||||
},
|
||||
etag: typeof result === "string" ? result : "",
|
||||
};
|
||||
|
||||
// normally only etag is returned
|
||||
if (typeof result === "object") {
|
||||
info = result;
|
||||
} else if (isFile(file)) {
|
||||
info.meta.size = file.size;
|
||||
info.meta.type = file.type;
|
||||
}
|
||||
|
||||
// try to get better meta info
|
||||
if (
|
||||
!info.meta.type ||
|
||||
["application/octet-stream", "application/json"].includes(info.meta.type) ||
|
||||
!info.meta.size
|
||||
) {
|
||||
if (!isMimeType(info.meta.type, ["application/octet-stream", "application/json"])) {
|
||||
const meta = await this.#adapter.getObjectMeta(name);
|
||||
if (!meta) {
|
||||
throw new Error("Failed to get object meta");
|
||||
|
||||
@@ -11,14 +11,12 @@ export async function adapterTestSuite(
|
||||
retries?: number;
|
||||
retryTimeout?: number;
|
||||
skipExistsAfterDelete?: boolean;
|
||||
testRange?: boolean;
|
||||
},
|
||||
) {
|
||||
const { test, expect } = testRunner;
|
||||
const options = {
|
||||
retries: opts?.retries ?? 1,
|
||||
retryTimeout: opts?.retryTimeout ?? 1000,
|
||||
testRange: opts?.testRange ?? true,
|
||||
};
|
||||
|
||||
let objects = 0;
|
||||
@@ -55,34 +53,9 @@ export async function adapterTestSuite(
|
||||
await test("gets an object", async () => {
|
||||
const res = await adapter.getObject(filename, new Headers());
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
// @todo: check the content
|
||||
});
|
||||
|
||||
if (options.testRange) {
|
||||
await test("handles range request - partial content", async () => {
|
||||
const headers = new Headers({ Range: "bytes=0-99" });
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(206); // Partial Content
|
||||
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||
});
|
||||
|
||||
await test("handles range request - suffix range", async () => {
|
||||
const headers = new Headers({ Range: "bytes=-100" });
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(206); // Partial Content
|
||||
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||
});
|
||||
|
||||
await test("handles invalid range request", async () => {
|
||||
const headers = new Headers({ Range: "bytes=invalid" });
|
||||
const res = await adapter.getObject(filename, headers);
|
||||
expect(res.status).toBe(416); // Range Not Satisfiable
|
||||
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
await test("gets object meta", async () => {
|
||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
||||
type: file.type, // image/png
|
||||
|
||||
@@ -22,19 +22,6 @@ afterAll(() => {
|
||||
cleanup();
|
||||
}); */
|
||||
|
||||
describe("StorageS3Adapter", async () => {
|
||||
test("verify client's service is set to s3", async () => {
|
||||
const adapter = new StorageS3Adapter({
|
||||
access_key: "",
|
||||
secret_access_key: "",
|
||||
url: "https://localhost",
|
||||
});
|
||||
// this is important for minio to produce the correct headers
|
||||
// and it won't harm s3 or r2
|
||||
expect(adapter.client.service).toBe("s3");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
|
||||
if (ALL_TESTS) return;
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ export class StorageS3Adapter extends StorageAdapter {
|
||||
accessKeyId: config.access_key,
|
||||
secretAccessKey: config.secret_access_key,
|
||||
retries: isDebug() ? 0 : 10,
|
||||
service: "s3",
|
||||
},
|
||||
{
|
||||
convertParams: "pascalToKebab",
|
||||
|
||||
@@ -3,7 +3,7 @@ export const Q = {
|
||||
audio: ["ogg"],
|
||||
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff", "avif", "heic", "heif"],
|
||||
text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"],
|
||||
application: ["zip", "toml", "json", "json5", "pdf", "xml"],
|
||||
application: ["zip", "xml", "toml", "json", "json5"],
|
||||
font: ["woff", "woff2", "ttf", "otf"],
|
||||
} as const;
|
||||
|
||||
@@ -15,13 +15,11 @@ const c = {
|
||||
a: (w = "octet-stream") => `application/${w}`,
|
||||
i: (w) => `image/${w}`,
|
||||
v: (w) => `video/${w}`,
|
||||
au: (w) => `audio/${w}`,
|
||||
} as const;
|
||||
export const M = new Map<string, string>([
|
||||
["7z", c.z],
|
||||
["7zip", c.z],
|
||||
["txt", c.t()],
|
||||
["ai", c.a("postscript")],
|
||||
["ai", c.a("pdf")],
|
||||
["apk", c.a("vnd.android.package-archive")],
|
||||
["doc", c.a("msword")],
|
||||
["docx", `${c.vnd}.wordprocessingml.document`],
|
||||
@@ -34,12 +32,12 @@ export const M = new Map<string, string>([
|
||||
["jpg", c.i("jpeg")],
|
||||
["js", c.t("javascript")],
|
||||
["log", c.t()],
|
||||
["m3u", c.au("x-mpegurl")],
|
||||
["m3u", c.t()],
|
||||
["m3u8", c.a("vnd.apple.mpegurl")],
|
||||
["manifest", c.t("cache-manifest")],
|
||||
["md", c.t("markdown")],
|
||||
["mkv", c.v("x-matroska")],
|
||||
["mp3", c.au("mpeg")],
|
||||
["mp3", c.a("mpeg")],
|
||||
["mobi", c.a("x-mobipocket-ebook")],
|
||||
["ppt", c.a("powerpoint")],
|
||||
["pptx", `${c.vnd}.presentationml.presentation`],
|
||||
@@ -48,10 +46,11 @@ export const M = new Map<string, string>([
|
||||
["tif", c.i("tiff")],
|
||||
["tsv", c.t("tab-separated-values")],
|
||||
["tgz", c.a("x-tar")],
|
||||
["txt", c.t()],
|
||||
["text", c.t()],
|
||||
["vcd", c.a("x-cdlink")],
|
||||
["vcs", c.t("x-vcalendar")],
|
||||
["wav", c.au("vnd.wav")],
|
||||
["wav", c.a("x-wav")],
|
||||
["webmanifest", c.a("manifest+json")],
|
||||
["xls", c.a("vnd.ms-excel")],
|
||||
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
SecretSchema,
|
||||
setPath,
|
||||
mark,
|
||||
$console,
|
||||
} from "bknd/utils";
|
||||
import { DebugLogger } from "core/utils/DebugLogger";
|
||||
import { Guard } from "auth/authorize/Guard";
|
||||
@@ -127,7 +126,7 @@ export class ModuleManager {
|
||||
|
||||
constructor(
|
||||
protected readonly connection: Connection,
|
||||
public options?: Partial<ModuleManagerOptions>,
|
||||
protected options?: Partial<ModuleManagerOptions>,
|
||||
) {
|
||||
this.modules = {} as Modules;
|
||||
this.emgr = new EventManager({ ...ModuleManagerEvents });
|
||||
@@ -331,8 +330,9 @@ export class ModuleManager {
|
||||
ctx.flags.sync_required = false;
|
||||
this.logger.log("db sync requested");
|
||||
|
||||
// ignore sync request on code mode since system tables
|
||||
// are probably never fully in provided config
|
||||
// sync db
|
||||
await ctx.em.schema().sync({ force: true, drop: options?.drop });
|
||||
state.synced = true;
|
||||
}
|
||||
|
||||
if (ctx.flags.ctx_reload_required) {
|
||||
|
||||
+14
-16
@@ -8,9 +8,21 @@ import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
|
||||
import { createMantineTheme } from "./lib/mantine/theme";
|
||||
import { Routes } from "./routes";
|
||||
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
|
||||
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "ui/options";
|
||||
|
||||
export type BkndAdminConfig = {
|
||||
export type BkndAdminProps = {
|
||||
/**
|
||||
* Base URL of the API, only needed if you are not using the `withProvider` prop
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Whether to wrap Admin in a <ClientProvider />
|
||||
*/
|
||||
withProvider?: boolean | ClientProviderProps;
|
||||
/**
|
||||
* Admin UI customization options
|
||||
*/
|
||||
config?: {
|
||||
/**
|
||||
* Base path of the Admin UI
|
||||
* @default `/`
|
||||
@@ -35,20 +47,6 @@ export type BkndAdminConfig = {
|
||||
*/
|
||||
appShell?: BkndAdminAppShellOptions;
|
||||
};
|
||||
|
||||
export type BkndAdminProps = {
|
||||
/**
|
||||
* Base URL of the API, only needed if you are not using the `withProvider` prop
|
||||
*/
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Whether to wrap Admin in a `<ClientProvider />`
|
||||
*/
|
||||
withProvider?: boolean | ClientProviderProps;
|
||||
/**
|
||||
* Admin UI customization options
|
||||
*/
|
||||
config?: BkndAdminConfig;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export const useApiInfiniteQuery = <
|
||||
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data,
|
||||
>(
|
||||
fn: (api: Api, page: number) => FetchPromise<Data>,
|
||||
options?: SWRConfiguration & { refine?: RefineFn; pageSize?: number },
|
||||
options?: SWRConfiguration & { refine?: RefineFn },
|
||||
) => {
|
||||
const [endReached, setEndReached] = useState(false);
|
||||
const api = useApi();
|
||||
@@ -47,7 +47,7 @@ export const useApiInfiniteQuery = <
|
||||
// @ts-ignore
|
||||
const swr = useSWRInfinite<RefinedData>(
|
||||
(index, previousPageData: any) => {
|
||||
if (index > 0 && previousPageData && previousPageData.length < (options?.pageSize ?? 0)) {
|
||||
if (previousPageData && !previousPageData.length) {
|
||||
setEndReached(true);
|
||||
return null; // reached the end
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import type {
|
||||
DB,
|
||||
PrimaryFieldType,
|
||||
EntityData,
|
||||
RepoQueryIn,
|
||||
RepositoryResult,
|
||||
ResponseObject,
|
||||
ModuleApi,
|
||||
} from "bknd";
|
||||
import type { DB, PrimaryFieldType, EntityData, RepoQueryIn } from "bknd";
|
||||
import { objectTransform, encodeSearch } from "bknd/utils";
|
||||
import type { RepositoryResult } from "data/entities";
|
||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
||||
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
|
||||
import { type Api, useApi } from "ui/client";
|
||||
|
||||
@@ -114,7 +108,7 @@ export function makeKey(
|
||||
);
|
||||
}
|
||||
|
||||
export interface UseEntityQueryReturn<
|
||||
interface UseEntityQueryReturn<
|
||||
Entity extends keyof DB | string,
|
||||
Id extends PrimaryFieldType | undefined = undefined,
|
||||
Data = Entity extends keyof DB ? Selectable<DB[Entity]> : EntityData,
|
||||
@@ -142,11 +136,11 @@ export const useEntityQuery = <
|
||||
const fetcher = () => read(query ?? {});
|
||||
|
||||
type T = Awaited<ReturnType<typeof fetcher>>;
|
||||
const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, {
|
||||
const swr = useSWR<T>(options?.enabled === false ? null : key, fetcher as any, {
|
||||
revalidateOnFocus: false,
|
||||
keepPreviousData: true,
|
||||
...options,
|
||||
}) as ReturnType<typeof useSWR<T>>;
|
||||
});
|
||||
|
||||
const mutateFn = async (id?: PrimaryFieldType) => {
|
||||
const entityKey = makeKey(api, entity as string, id);
|
||||
|
||||
@@ -53,7 +53,7 @@ export type DataTableProps<Data> = {
|
||||
};
|
||||
|
||||
export function DataTable<Data extends Record<string, any> = Record<string, any>>({
|
||||
data: _data = [],
|
||||
data = [],
|
||||
columns,
|
||||
checkable,
|
||||
onClickRow,
|
||||
@@ -71,14 +71,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
renderValue,
|
||||
onClickNew,
|
||||
}: DataTableProps<Data>) {
|
||||
const hasTotal = !!total;
|
||||
const data = Array.isArray(_data) ? _data.slice(0, perPage) : _data;
|
||||
total = total || data?.length || 0;
|
||||
page = page || 1;
|
||||
|
||||
const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {});
|
||||
const pages = Math.max(Math.ceil(total / perPage), 1);
|
||||
const hasNext = hasTotal ? pages > page : (_data?.length || 0) > perPage;
|
||||
const CellRender = renderValue || CellValue;
|
||||
|
||||
return (
|
||||
@@ -205,7 +202,7 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
items={data?.length || 0}
|
||||
total={hasTotal ? total : undefined}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 md:gap-10 items-center">
|
||||
@@ -225,17 +222,11 @@ export function DataTable<Data extends Record<string, any> = Record<string, any>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-primary/40">
|
||||
Page {page}
|
||||
{hasTotal ? <> of {pages}</> : ""}
|
||||
Page {page} of {pages}
|
||||
</div>
|
||||
{onClickPage && (
|
||||
<div className="flex flex-row gap-1.5">
|
||||
<TableNav
|
||||
current={page}
|
||||
total={hasTotal ? pages : page + (hasNext ? 1 : 0)}
|
||||
onClick={onClickPage}
|
||||
hasLast={hasTotal}
|
||||
/>
|
||||
<TableNav current={page} total={pages} onClick={onClickPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -277,23 +268,17 @@ const SortIndicator = ({
|
||||
};
|
||||
|
||||
const TableDisplay = ({ perPage, page, items, total }) => {
|
||||
if (items === 0 && page === 1) {
|
||||
if (total === 0) {
|
||||
return <>No rows to show</>;
|
||||
}
|
||||
|
||||
const start = Math.max(perPage * (page - 1), 1);
|
||||
|
||||
if (!total) {
|
||||
return (
|
||||
<>
|
||||
Showing {start}-{perPage * (page - 1) + items}
|
||||
</>
|
||||
);
|
||||
if (total === 1) {
|
||||
return <>Showing 1 row</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Showing {start}-{perPage * (page - 1) + items} of {total} rows
|
||||
Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -302,36 +287,23 @@ type TableNavProps = {
|
||||
current: number;
|
||||
total: number;
|
||||
onClick?: (page: number) => void;
|
||||
hasLast?: boolean;
|
||||
};
|
||||
|
||||
const TableNav: React.FC<TableNavProps> = ({
|
||||
current,
|
||||
total,
|
||||
onClick,
|
||||
hasLast = true,
|
||||
}: TableNavProps) => {
|
||||
const TableNav: React.FC<TableNavProps> = ({ current, total, onClick }: TableNavProps) => {
|
||||
const navMap = [
|
||||
{ enabled: true, value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ enabled: true, value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{
|
||||
enabled: true,
|
||||
value: current + 1,
|
||||
Icon: TbChevronRight,
|
||||
disabled: current === total,
|
||||
},
|
||||
{ enabled: hasLast, value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||
{ value: 1, Icon: TbChevronsLeft, disabled: current === 1 },
|
||||
{ value: current - 1, Icon: TbChevronLeft, disabled: current === 1 },
|
||||
{ value: current + 1, Icon: TbChevronRight, disabled: current === total },
|
||||
{ value: total, Icon: TbChevronsRight, disabled: current === total },
|
||||
] as const;
|
||||
|
||||
return navMap.map(
|
||||
(nav, key) =>
|
||||
nav.enabled && (
|
||||
return navMap.map((nav, key) => (
|
||||
<button
|
||||
role="button"
|
||||
type="button"
|
||||
key={key}
|
||||
disabled={nav.disabled}
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||
className="px-2 py-2 border-muted border rounded-md enabled:link text-lg enabled:hover:bg-primary/5 text-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
const page = nav.value;
|
||||
const safePage = page < 1 ? 1 : page > total ? total : page;
|
||||
@@ -340,6 +312,5 @@ const TableNav: React.FC<TableNavProps> = ({
|
||||
>
|
||||
<nav.Icon />
|
||||
</button>
|
||||
),
|
||||
);
|
||||
));
|
||||
};
|
||||
|
||||
@@ -42,10 +42,7 @@ export type DropzoneRenderProps = {
|
||||
showPlaceholder: boolean;
|
||||
onClick?: (file: { path: string }) => void;
|
||||
footer?: ReactNode;
|
||||
dropzoneProps: Pick<
|
||||
DropzoneProps,
|
||||
"maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes"
|
||||
>;
|
||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||
};
|
||||
|
||||
export type DropzoneProps = {
|
||||
@@ -154,7 +151,6 @@ export function Dropzone({
|
||||
const setIsOver = useStore(store, (state) => state.setIsOver);
|
||||
const uploading = useStore(store, (state) => state.uploading);
|
||||
const setFileState = useStore(store, (state) => state.setFileState);
|
||||
const overrideFile = useStore(store, (state) => state.overrideFile);
|
||||
const removeFile = useStore(store, (state) => state.removeFile);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -173,14 +169,9 @@ export function Dropzone({
|
||||
|
||||
return specs.every((spec) => {
|
||||
if (spec.kind !== "file") {
|
||||
console.log("not a file", spec.kind);
|
||||
return false;
|
||||
}
|
||||
if (allowedMimeTypes && allowedMimeTypes.length > 0) {
|
||||
console.log("not allowed mimetype", spec.type);
|
||||
return allowedMimeTypes.includes(spec.type);
|
||||
}
|
||||
return true;
|
||||
return !(allowedMimeTypes && !allowedMimeTypes.includes(spec.type));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -368,7 +359,7 @@ export function Dropzone({
|
||||
state: "uploaded",
|
||||
};
|
||||
|
||||
overrideFile(file.path, newState);
|
||||
setFileState(file.path, newState.state);
|
||||
resolve({ ...response, ...file, ...newState });
|
||||
} catch (e) {
|
||||
setFileState(file.path, "uploaded", 1);
|
||||
@@ -391,9 +382,7 @@ export function Dropzone({
|
||||
};
|
||||
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
const formData = new FormData();
|
||||
formData.append("file", file.body);
|
||||
xhr.send(formData);
|
||||
xhr.send(file.body);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -422,9 +411,7 @@ export function Dropzone({
|
||||
const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
|
||||
const showPlaceholder = useMemo(
|
||||
() =>
|
||||
Boolean(
|
||||
placeholder?.show !== false && (!maxItems || (maxItems && files.length < maxItems)),
|
||||
),
|
||||
Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)),
|
||||
[placeholder, maxItems, files.length],
|
||||
);
|
||||
|
||||
@@ -437,7 +424,6 @@ export function Dropzone({
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange,
|
||||
accept: allowedMimeTypes?.join(","),
|
||||
},
|
||||
showPlaceholder,
|
||||
actions: {
|
||||
@@ -451,12 +437,11 @@ export function Dropzone({
|
||||
placeholder,
|
||||
autoUpload,
|
||||
flow,
|
||||
allowedMimeTypes,
|
||||
},
|
||||
onClick,
|
||||
footer,
|
||||
}),
|
||||
[maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes],
|
||||
[maxItems, flow, placeholder, autoUpload, footer],
|
||||
) as unknown as DropzoneRenderProps;
|
||||
|
||||
return (
|
||||
|
||||
@@ -77,9 +77,7 @@ export function DropzoneContainer({
|
||||
});
|
||||
|
||||
const $q = infinite
|
||||
? useApiInfiniteQuery(selectApi, {
|
||||
pageSize,
|
||||
})
|
||||
? useApiInfiniteQuery(selectApi, {})
|
||||
: useApiQuery(selectApi, {
|
||||
enabled: initialItems !== false && !initialItems,
|
||||
revalidateOnFocus: false,
|
||||
@@ -110,33 +108,14 @@ export function DropzoneContainer({
|
||||
[]) as MediaFieldSchema[];
|
||||
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
|
||||
|
||||
const key = id + JSON.stringify(initialItems);
|
||||
|
||||
// check if endpoint reeturns a total, then reaching end is easy
|
||||
const total = "_data" in $q ? $q._data?.[0]?.body.meta.count : undefined;
|
||||
let placeholderLength = 0;
|
||||
if (infinite && "setSize" in $q) {
|
||||
placeholderLength =
|
||||
typeof total === "number"
|
||||
? total
|
||||
: $q.endReached
|
||||
? _initialItems.length
|
||||
: _initialItems.length + pageSize;
|
||||
|
||||
// in case there is no total, we overfetch but SWR don't reflect an empty result
|
||||
// therefore we check if it stopped loading, but has a bigger page size than the total.
|
||||
// if that's the case, we assume we reached the end.
|
||||
if (!total && !$q.isValidating && pageSize * $q.size >= placeholderLength) {
|
||||
placeholderLength = _initialItems.length;
|
||||
}
|
||||
}
|
||||
|
||||
const key = id + JSON.stringify(_initialItems);
|
||||
return (
|
||||
<>
|
||||
<Dropzone
|
||||
key={key}
|
||||
key={id + key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
/* onUploaded={refresh}
|
||||
onDeleted={refresh} */
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
@@ -144,14 +123,16 @@ export function DropzoneContainer({
|
||||
"setSize" in $q && (
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={placeholderLength}
|
||||
length={Math.min(
|
||||
$q._data?.[0]?.body.meta.count ?? 0,
|
||||
_initialItems.length + pageSize,
|
||||
)}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { type ComponentPropsWithoutRef, memo, type ReactNode, useCallback, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useRenderCount } from "ui/hooks/use-render-count";
|
||||
import {
|
||||
TbDots,
|
||||
TbExternalLink,
|
||||
TbFileTypeCsv,
|
||||
TbFileText,
|
||||
TbJson,
|
||||
TbFileTypePdf,
|
||||
TbMarkdown,
|
||||
TbMusic,
|
||||
TbTrash,
|
||||
TbUpload,
|
||||
TbFileTypeTxt,
|
||||
TbFileTypeXml,
|
||||
TbZip,
|
||||
TbFileTypeSql,
|
||||
} from "react-icons/tb";
|
||||
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { formatNumber } from "core/utils";
|
||||
@@ -37,7 +22,7 @@ export const DropzoneInner = ({
|
||||
inputProps,
|
||||
showPlaceholder,
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow, maxItems, allowedMimeTypes },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
@@ -100,7 +85,7 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
);
|
||||
};
|
||||
|
||||
type ReducedFile = Omit<FileState, "state" | "progress">;
|
||||
type ReducedFile = Pick<FileState, "body" | "type" | "path" | "name" | "size">;
|
||||
export type PreviewComponentProps = {
|
||||
file: ReducedFile;
|
||||
fallback?: (props: { file: ReducedFile }) => ReactNode;
|
||||
@@ -174,9 +159,9 @@ const Preview = memo(
|
||||
<p className="truncate select-text w-[calc(100%-10px)]">{file.name}</p>
|
||||
<StateIndicator file={file} />
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-xs md:text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate select-text">{file.type}</span>
|
||||
<span className="whitespace-nowrap">{formatNumber.fileSize(file.size)}</span>
|
||||
<span>{formatNumber.fileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,59 +271,6 @@ const VideoPreview = ({
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const Previews = [
|
||||
{
|
||||
mime: "text/plain",
|
||||
Icon: TbFileTypeTxt,
|
||||
},
|
||||
{
|
||||
mime: "text/csv",
|
||||
Icon: TbFileTypeCsv,
|
||||
},
|
||||
{
|
||||
mime: /(text|application)\/xml/,
|
||||
Icon: TbFileTypeXml,
|
||||
},
|
||||
{
|
||||
mime: "text/markdown",
|
||||
Icon: TbMarkdown,
|
||||
},
|
||||
{
|
||||
mime: /^text\/.*$/,
|
||||
Icon: TbFileText,
|
||||
},
|
||||
{
|
||||
mime: "application/json",
|
||||
Icon: TbJson,
|
||||
},
|
||||
{
|
||||
mime: "application/pdf",
|
||||
Icon: TbFileTypePdf,
|
||||
},
|
||||
{
|
||||
mime: /^audio\/.*$/,
|
||||
Icon: TbMusic,
|
||||
},
|
||||
{
|
||||
mime: "application/zip",
|
||||
Icon: TbZip,
|
||||
},
|
||||
{
|
||||
mime: "application/sql",
|
||||
Icon: TbFileTypeSql,
|
||||
},
|
||||
];
|
||||
|
||||
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
|
||||
const previewIcon = Previews.find((p) =>
|
||||
p.mime instanceof RegExp ? p.mime.test(file.type) : p.mime === file.type,
|
||||
);
|
||||
if (previewIcon) {
|
||||
return <previewIcon.Icon className="size-10 text-gray-400" />;
|
||||
}
|
||||
return (
|
||||
<div className="text-xs text-primary/50 text-center font-mono leading-none max-w-[90%] truncate">
|
||||
{file.type}
|
||||
</div>
|
||||
);
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
};
|
||||
|
||||
@@ -36,10 +36,6 @@ export const createDropzoneStore = () => {
|
||||
: f,
|
||||
),
|
||||
})),
|
||||
overrideFile: (path: string, newState: Partial<FileState>) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((f) => (f.path === path ? { ...f, ...newState } : f)),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,10 @@ export function useSearch<Schema extends s.Schema = s.Schema>(
|
||||
) {
|
||||
const searchString = useWouterSearch();
|
||||
const [location, navigate] = useLocation();
|
||||
const [value, setValue] = useState<s.StaticCoerced<Schema>>(
|
||||
options?.defaultValue ?? ({} as any),
|
||||
);
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
return mergeObject(
|
||||
// @ts-ignore
|
||||
@@ -21,7 +25,6 @@ export function useSearch<Schema extends s.Schema = s.Schema>(
|
||||
options?.defaultValue ?? {},
|
||||
);
|
||||
}, [JSON.stringify({ schema, dflt: options?.defaultValue })]);
|
||||
const [value, setValue] = useState<s.StaticCoerced<Schema>>(defaults);
|
||||
|
||||
useEffect(() => {
|
||||
const initial =
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export { default as Admin, type BkndAdminProps, type BkndAdminConfig } from "./Admin";
|
||||
export { default as Admin, type BkndAdminProps } from "./Admin";
|
||||
export * from "./components/form/json-schema-form";
|
||||
export { JsonViewer } from "./components/code/JsonViewer";
|
||||
export type * from "./options";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ContextModalProps } from "@mantine/modals";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEntityQuery } from "ui/client";
|
||||
import { type FileState, Media } from "ui/elements";
|
||||
import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils";
|
||||
@@ -43,7 +43,7 @@ export function MediaInfoModal({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-50">
|
||||
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
|
||||
<FilePreview file={file} />
|
||||
</div>
|
||||
<div className="w-full md:!w-[300px] flex flex-col">
|
||||
@@ -156,38 +156,12 @@ const Item = ({
|
||||
);
|
||||
};
|
||||
|
||||
const textFormats = [/^text\/.*$/, /application\/(x\-)?(json|json|yaml|javascript|xml|rtf|sql)/];
|
||||
|
||||
const FilePreview = ({ file }: { file: FileState }) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
|
||||
if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
|
||||
// @ts-ignore
|
||||
return <Media.Preview file={file} className="max-h-[70dvh]" controls muted />;
|
||||
}
|
||||
|
||||
if (file.type === "application/pdf") {
|
||||
// use browser preview
|
||||
return (
|
||||
<iframe
|
||||
title="PDF preview"
|
||||
src={`${objectUrl}#view=fitH&zoom=page-width&toolbar=1`}
|
||||
className="w-250 max-w-[80dvw] h-[80dvh]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (textFormats.some((f) => f.test(file.type))) {
|
||||
return <TextPreview file={file} />;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("audio/")) {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<audio src={objectUrl} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-96 min-h-48 flex justify-center items-center h-full max-h-[70dvh]">
|
||||
<span className="opacity-50 font-mono">No Preview Available</span>
|
||||
@@ -195,44 +169,6 @@ const FilePreview = ({ file }: { file: FileState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const TextPreview = ({ file }: { file: FileState }) => {
|
||||
const [text, setText] = useState("");
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
const maxBytes = 1024 * 256;
|
||||
const useRange = file.size > maxBytes;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (file) {
|
||||
fetch(objectUrl, {
|
||||
headers: useRange ? { Range: `bytes=0-${maxBytes - 1}` } : undefined,
|
||||
})
|
||||
.then((r) => r.text())
|
||||
.then((t) => {
|
||||
if (!cancelled) setText(t);
|
||||
});
|
||||
} else {
|
||||
setText("");
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [file, useRange]);
|
||||
|
||||
return (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap break-all overflow-y-scroll w-250 md:max-w-[80dvw] h-[60dvh] md:h-[80dvh] py-4 px-6">
|
||||
{text}
|
||||
|
||||
{useRange && (
|
||||
<div className="mt-3 opacity-50 text-xs text-center">
|
||||
Showing first {formatNumber.fileSize(maxBytes)}
|
||||
</div>
|
||||
)}
|
||||
</pre>
|
||||
);
|
||||
};
|
||||
|
||||
MediaInfoModal.defaultTitle = undefined;
|
||||
MediaInfoModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
|
||||
@@ -240,10 +240,6 @@ function EntityMediaFormField({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
const maxLimit = 50;
|
||||
const maxItems = field.getMaxItems();
|
||||
const isSingle = maxItems === 1;
|
||||
const limit = isSingle ? 1 : maxItems && maxItems > maxLimit ? maxLimit : maxItems;
|
||||
|
||||
const value = useStore(formApi.store, (state) => {
|
||||
const val = state.values[field.name];
|
||||
@@ -264,9 +260,8 @@ function EntityMediaFormField({
|
||||
<FieldLabel field={field} />
|
||||
<Media.Dropzone
|
||||
key={key}
|
||||
maxItems={maxItems}
|
||||
allowedMimeTypes={field.getAllowedMimeTypes()}
|
||||
initialItems={isSingle ? value : undefined}
|
||||
maxItems={field.getMaxItems()}
|
||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||
onClick={onClick}
|
||||
entity={{
|
||||
name: entity.name,
|
||||
@@ -275,7 +270,6 @@ function EntityMediaFormField({
|
||||
}}
|
||||
query={{
|
||||
sort: "-id",
|
||||
limit,
|
||||
}}
|
||||
/>
|
||||
</Formy.Group>
|
||||
@@ -301,9 +295,19 @@ function EntityJsonFormField({
|
||||
onChange={handleUpdate}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
minHeight="100"
|
||||
/*required={field.isRequired()}*/
|
||||
{...props}
|
||||
/>
|
||||
</Suspense>
|
||||
{/*<Formy.Textarea
|
||||
name={fieldApi.name}
|
||||
id={fieldApi.name}
|
||||
value={fieldApi.state.value}
|
||||
onBlur={fieldApi.handleBlur}
|
||||
onChange={handleUpdate}
|
||||
required={field.isRequired()}
|
||||
{...props}
|
||||
/>*/}
|
||||
</Formy.Group>
|
||||
);
|
||||
}
|
||||
@@ -330,8 +334,8 @@ function EntityEnumFormField({
|
||||
{...props}
|
||||
>
|
||||
{!field.isRequired() && <option value="">- Select -</option>}
|
||||
{field.getOptions().map((option, i) => (
|
||||
<option key={`${option.value}-${i}`} value={option.value}>
|
||||
{field.getOptions().map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function EntityRelationalFormField({
|
||||
const ref = useRef<any>(null);
|
||||
const $q = useEntityQuery(field.target(), undefined, {
|
||||
select: query.select,
|
||||
limit: query.limit + 1 /* overfetch for softscan=false */,
|
||||
limit: query.limit,
|
||||
offset: (query.page - 1) * query.limit,
|
||||
});
|
||||
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
|
||||
|
||||
@@ -61,7 +61,7 @@ function DataEntityListImpl({ params }) {
|
||||
(api) =>
|
||||
api.data.readMany(entity?.name as any, {
|
||||
select: search.value.select,
|
||||
limit: search.value.perPage + 1 /* overfetch for softscan=false */,
|
||||
limit: search.value.perPage,
|
||||
offset: (search.value.page - 1) * search.value.perPage,
|
||||
sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}`,
|
||||
}),
|
||||
|
||||
+2
-39
@@ -1,4 +1,4 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { showRoutes } from "hono/dev";
|
||||
import { App, registries, type CreateAppConfig } from "./src";
|
||||
@@ -9,7 +9,6 @@ import { $console } from "core/utils/console";
|
||||
import { createClient } from "@libsql/client";
|
||||
import util from "node:util";
|
||||
import { d1Sqlite } from "adapter/cloudflare/connection/D1Connection";
|
||||
import { slugify } from "./src/core/utils/strings";
|
||||
|
||||
util.inspect.defaultOptions.depth = 5;
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
@@ -22,7 +21,6 @@ $console.debug("Using db type", dbType);
|
||||
let dbUrl = import.meta.env.VITE_DB_URL ?? ":memory:";
|
||||
|
||||
const example = import.meta.env.VITE_EXAMPLE;
|
||||
async function loadExampleConfig() {
|
||||
if (example) {
|
||||
const configPath = `.configs/${example}.json`;
|
||||
$console.debug("Loading config from", configPath);
|
||||
@@ -30,11 +28,9 @@ async function loadExampleConfig() {
|
||||
config.config = exampleConfig;
|
||||
dbUrl = `file:.configs/${example}.db`;
|
||||
}
|
||||
}
|
||||
|
||||
switch (dbType) {
|
||||
case "libsql": {
|
||||
await loadExampleConfig();
|
||||
$console.debug("Using libsql connection", dbUrl);
|
||||
const authToken = import.meta.env.VITE_DB_LIBSQL_TOKEN;
|
||||
config.connection = libsql(
|
||||
@@ -47,48 +43,15 @@ switch (dbType) {
|
||||
}
|
||||
case "d1": {
|
||||
$console.debug("Using d1 connection");
|
||||
const wranglerConfig = {
|
||||
name: "vite-dev",
|
||||
main: "src/index.ts",
|
||||
compatibility_date: "2025-08-03",
|
||||
compatibility_flags: ["nodejs_compat"],
|
||||
d1_databases: [
|
||||
{
|
||||
binding: "DB",
|
||||
database_name: "vite-dev",
|
||||
database_id: "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
],
|
||||
r2_buckets: [
|
||||
{
|
||||
binding: "BUCKET",
|
||||
bucket_name: "vite-dev",
|
||||
},
|
||||
],
|
||||
};
|
||||
let configPath = ".configs/vite.wrangler.json";
|
||||
if (example) {
|
||||
const name = slugify(example);
|
||||
configPath = `.configs/${slugify(example)}.wrangler.json`;
|
||||
const exists = await readFile(configPath, "utf-8");
|
||||
if (!exists) {
|
||||
wranglerConfig.name = name;
|
||||
wranglerConfig.d1_databases[0]!.database_name = name;
|
||||
wranglerConfig.d1_databases[0]!.database_id = crypto.randomUUID();
|
||||
wranglerConfig.r2_buckets[0]!.bucket_name = name;
|
||||
await writeFile(configPath, JSON.stringify(wranglerConfig, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
const { getPlatformProxy } = await import("wrangler");
|
||||
const platformProxy = await getPlatformProxy({
|
||||
configPath,
|
||||
configPath: "./vite.wrangler.json",
|
||||
});
|
||||
config.connection = d1Sqlite({ binding: platformProxy.env.DB as any });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
await loadExampleConfig();
|
||||
$console.debug("Using node-sqlite connection", dbUrl);
|
||||
config.connection = nodeSqlite({ url: dbUrl });
|
||||
break;
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
---
|
||||
title: Admin UI
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
import { TypeTable } from "fumadocs-ui/components/type-table";
|
||||
|
||||
|
||||
bknd features an integrated Admin UI that can be used to:
|
||||
- fully manage your backend visually when run in [`db` mode](/usage/introduction/#ui-only-mode)
|
||||
- manage your database contents
|
||||
- manage your media contents
|
||||
|
||||
In case you're using bknd with a [React framework](integration/introduction/#start-with-a-framework) and render the Admin as React component, you can go further and customize the Admin UI to your liking.
|
||||
|
||||
<AutoTypeTable path="../app/src/ui/Admin.tsx" name="BkndAdminProps" />
|
||||
|
||||
|
||||
## Advanced Example
|
||||
|
||||
The following example shows how to customize the Admin UI for each entity.
|
||||
|
||||
- adds a custom action item to the user menu (top right)
|
||||
- adds a custom action item to the entity list
|
||||
- adds a custom action item to the entity create/update form
|
||||
- overrides the rendering of the title field
|
||||
- renders a custom header for the entity
|
||||
- renders a custom footer for the entity
|
||||
- adds a custom route
|
||||
|
||||
```tsx
|
||||
import { Admin } from "bknd/ui";
|
||||
import { Route } from "wouter";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Admin
|
||||
withProvider
|
||||
config={{
|
||||
appShell: {
|
||||
// add a custom user menu item (top right)
|
||||
userMenu: [
|
||||
{
|
||||
label: "Custom",
|
||||
onClick: () => alert("custom"),
|
||||
},
|
||||
],
|
||||
},
|
||||
entities: {
|
||||
// use any entity that is registered
|
||||
tests: {
|
||||
actions: (context, entity, data) => ({
|
||||
primary: [
|
||||
// this action is only rendered in the update context
|
||||
context === "update" && {
|
||||
children: "another",
|
||||
onClick: () => alert("another"),
|
||||
},
|
||||
],
|
||||
context: [
|
||||
// this action is always rendered in the dropdown
|
||||
{
|
||||
label: "Custom",
|
||||
onClick: () =>
|
||||
alert(
|
||||
"custom: " +
|
||||
JSON.stringify({ context, entity: entity.name, data }),
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
// render a header for the entity
|
||||
header: (context, entity, data) => <div>test header</div>,
|
||||
// override the rendering of the title field
|
||||
fields: {
|
||||
title: {
|
||||
render: (context, entity, field, ctx) => {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={ctx.value}
|
||||
onChange={(e) => ctx.handleChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// system entities work too
|
||||
users: {
|
||||
header: () => {
|
||||
return <div>System entity</div>;
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* You may also add custom routes, these always have precedence over the Admin routes */}
|
||||
<Route path="/data/custom">
|
||||
<div>custom</div>
|
||||
</Route>
|
||||
</Admin>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## `config`
|
||||
|
||||
<AutoTypeTable path="../app/src/ui/Admin.tsx" name="BkndAdminConfig" />
|
||||
|
||||
### `entities`
|
||||
|
||||
With the `entities` option, you can customize the Admin UI for each entity. You can override the header, footer, add additional actions, and override each field rendering.
|
||||
|
||||
```ts
|
||||
export type BkndAdminEntityContext = "list" | "create" | "update";
|
||||
|
||||
export type BkndAdminEntitiesOptions = {
|
||||
[E in keyof DB]?: BkndAdminEntityOptions<E>;
|
||||
};
|
||||
|
||||
export type BkndAdminEntityOptions<E extends keyof DB | string> = {
|
||||
/**
|
||||
* Header to be rendered depending on the context
|
||||
*/
|
||||
header?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
data?: DB[E],
|
||||
) => ReactNode | void | undefined;
|
||||
/**
|
||||
* Footer to be rendered depending on the context
|
||||
*/
|
||||
footer?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
data?: DB[E],
|
||||
) => ReactNode | void | undefined;
|
||||
/**
|
||||
* Actions to be rendered depending on the context
|
||||
*/
|
||||
actions?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
data?: DB[E],
|
||||
) => {
|
||||
/**
|
||||
* Primary actions are always visible
|
||||
*/
|
||||
primary?: (ButtonProps | undefined | null | false)[];
|
||||
/**
|
||||
* Context actions are rendered in a dropdown
|
||||
*/
|
||||
context?: DropdownProps["items"];
|
||||
};
|
||||
/**
|
||||
* Field UI overrides
|
||||
*/
|
||||
fields?: {
|
||||
[F in keyof DB[E]]?: BkndAdminEntityFieldOptions<E>;
|
||||
};
|
||||
};
|
||||
|
||||
export type BkndAdminEntityFieldOptions<E extends keyof DB | string> = {
|
||||
/**
|
||||
* Override the rendering of a certain field
|
||||
*/
|
||||
render?: (
|
||||
context: BkndAdminEntityContext,
|
||||
entity: Entity,
|
||||
field: Field,
|
||||
ctx: {
|
||||
data?: DB[E];
|
||||
value?: DB[E][keyof DB[E]];
|
||||
handleChange: (value: any) => void;
|
||||
},
|
||||
) => ReactNode | void | undefined;
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
### `appShell`
|
||||
|
||||
```ts
|
||||
export type DropdownItem =
|
||||
| (() => ReactNode)
|
||||
| {
|
||||
label: string | ReactElement;
|
||||
icon?: any;
|
||||
onClick?: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type BkndAdminAppShellOptions = {
|
||||
userMenu?: (DropdownItem | undefined | boolean)[];
|
||||
};
|
||||
```
|
||||
@@ -23,6 +23,26 @@ export default {
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
The `BkndConfig` extends the [`CreateAppConfig`](/usage/introduction#configuration-createappconfig) type with the following properties:
|
||||
|
||||
{/* <AutoTypeTable path="../app/src/adapter/index.ts" name="BkndConfig" /> */}
|
||||
|
||||
|
||||
```typescript
|
||||
export type BkndConfig = CreateAppConfig & {
|
||||
// return the app configuration as object or from a function
|
||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||
// called before the app is built
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
// called after the app has been built
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
// passed as the first argument to the `App.build` method
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
```
|
||||
|
||||
The supported configuration file extensions are `js`, `ts`, `mjs`, `cjs` and `json`. Throughout the documentation, we'll use `ts` for the file extension.
|
||||
|
||||
## Example
|
||||
@@ -54,177 +74,7 @@ export default {
|
||||
} satisfies BkndConfig;
|
||||
```
|
||||
|
||||
|
||||
## Configuration (`BkndConfig`)
|
||||
|
||||
The `BkndConfig` type is the main configuration object for the `createApp` function. It has
|
||||
the following properties:
|
||||
|
||||
```typescript
|
||||
import type { App, InitialModuleConfigs, ModuleBuildContext, Connection, MaybePromise } from "bknd";
|
||||
import type { Config } from "@libsql/client";
|
||||
|
||||
type AppPlugin = (app: App) => Promise<void> | void;
|
||||
type ManagerOptions = {
|
||||
basePath?: string;
|
||||
trustFetched?: boolean;
|
||||
onFirstBoot?: () => Promise<void>;
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
};
|
||||
|
||||
type BkndConfig<Args = any> = {
|
||||
connection?: Connection | Config;
|
||||
config?: InitialModuleConfigs;
|
||||
options?: {
|
||||
plugins?: AppPlugin[];
|
||||
manager?: ManagerOptions;
|
||||
};
|
||||
app?: BkndConfig<Args> | ((args: Args) => MaybePromise<BkndConfig<Args>>);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app?: App) => Promise<void>;
|
||||
buildConfig?: {
|
||||
sync?: boolean;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### `connection`
|
||||
|
||||
The `connection` property is the main connection object to the database. It can be either an object with libsql config or the actual `Connection` class.
|
||||
|
||||
```ts
|
||||
// uses the default SQLite connection depending on the runtime
|
||||
const connection = { url: "<url>" };
|
||||
|
||||
// the same as above, but more explicit
|
||||
import { sqlite } from "bknd/adapter/sqlite";
|
||||
const connection = sqlite({ url: "<url>" });
|
||||
|
||||
// Node.js SQLite, default on Node.js
|
||||
import { nodeSqlite } from "bknd/adapter/node";
|
||||
const connection = nodeSqlite({ url: "<url>" });
|
||||
|
||||
// Bun SQLite, default on Bun
|
||||
import { bunSqlite } from "bknd/adapter/bun";
|
||||
const connection = bunSqlite({ url: "<url>" });
|
||||
|
||||
// LibSQL, default on Cloudflare
|
||||
import { libsql } from "bknd";
|
||||
const connection = libsql({ url: "<url>" });
|
||||
```
|
||||
|
||||
See a full list of available connections in the [Database](/usage/database) section. Alternatively, you can pass an instance of a `Connection` class directly, see [Custom Connection](/usage/database#custom-connection) as a reference.
|
||||
|
||||
If the connection object is omitted, the app will try to use an in-memory database.
|
||||
|
||||
### `config`
|
||||
|
||||
As configuration, you can either pass a partial configuration object or a complete one
|
||||
with a version number. The version number is used to automatically migrate the configuration up
|
||||
to the latest version upon boot ([`db` mode](/usage/introduction#ui-only-mode) only). The default configuration looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"cors": {
|
||||
"origin": "*",
|
||||
"allow_methods": [
|
||||
"GET",
|
||||
"POST",
|
||||
"PATCH",
|
||||
"PUT",
|
||||
"DELETE"
|
||||
],
|
||||
"allow_headers": [
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Authorization",
|
||||
"Accept"
|
||||
],
|
||||
"allow_credentials": true
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": false,
|
||||
"path": "/api/system/mcp",
|
||||
"logLevel": "emergency"
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"basepath": "/api/data",
|
||||
"default_primary_format": "integer",
|
||||
"entities": {},
|
||||
"relations": {},
|
||||
"indices": {}
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "",
|
||||
"alg": "HS256",
|
||||
"expires": 0,
|
||||
"issuer": "",
|
||||
"fields": [
|
||||
"id",
|
||||
"email",
|
||||
"role"
|
||||
]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "strict",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"partitioned": false,
|
||||
"renew": true,
|
||||
"pathSuccess": "/",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"hashing": "sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"guard": {
|
||||
"enabled": false
|
||||
},
|
||||
"roles": {}
|
||||
},
|
||||
"media": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/media",
|
||||
"entity_name": "media",
|
||||
"storage": {}
|
||||
},
|
||||
"flows": {
|
||||
"basepath": "/api/flows",
|
||||
"flows": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can use the [CLI](/usage/cli/#getting-the-configuration-config) to get the default configuration:
|
||||
|
||||
```sh
|
||||
npx bknd config --default --pretty
|
||||
```
|
||||
|
||||
To validate your configuration against a JSON schema, you can also dump the schema using the CLI:
|
||||
|
||||
```sh
|
||||
npx bknd schema
|
||||
```
|
||||
|
||||
To create an initial data structure, you can use helpers [described here](/usage/database#data-structure).
|
||||
|
||||
### `app`
|
||||
### `app` (CreateAppConfig)
|
||||
|
||||
The `app` property is a function that returns a `CreateAppConfig` object. It allows accessing the adapter specific environment variables. This is especially useful when using the [Cloudflare Workers](/integration/cloudflare) runtime, where the environment variables are only available inside the request handler.
|
||||
|
||||
@@ -279,52 +129,6 @@ export default {
|
||||
};
|
||||
```
|
||||
|
||||
### `options.plugins`
|
||||
|
||||
The `plugins` property is an array of functions that are called after the app has been built,
|
||||
but before its event is emitted. This is useful for adding custom routes or other functionality.
|
||||
A simple plugin that adds a custom route looks like this:
|
||||
|
||||
```ts
|
||||
import type { AppPlugin } from "bknd";
|
||||
|
||||
export const myPlugin: AppPlugin = (app) => {
|
||||
app.server.get("/hello", (c) => c.json({ hello: "world" }));
|
||||
};
|
||||
```
|
||||
|
||||
Since each plugin has full access to the `app` instance, it can add routes, modify the database
|
||||
structure, add custom middlewares, respond to or add events, etc. Plugins are very powerful, so
|
||||
make sure to only run trusted ones.
|
||||
|
||||
Read more about plugins in the [Plugins](/extending/plugins) section.
|
||||
|
||||
### `options.seed`
|
||||
|
||||
<Callout type="info">
|
||||
The seed function will only be executed on app's first boot in `"db"` mode. If a configuration already exists in the database, or in `"code"` mode, it will not be executed.
|
||||
</Callout>
|
||||
|
||||
The `seed` property is a function that is called when the app is booted for the first time. It is used to seed the database with initial data. The function is passed a `ModuleBuildContext` object:
|
||||
|
||||
```ts
|
||||
type ModuleBuildContext = {
|
||||
connection: Connection;
|
||||
server: Hono;
|
||||
em: EntityManager;
|
||||
emgr: EventManager;
|
||||
guard: Guard;
|
||||
};
|
||||
|
||||
const seed = async (ctx: ModuleBuildContext) => {
|
||||
// seed the database
|
||||
await ctx.em.mutator("todos").insertMany([
|
||||
{ title: "Learn bknd", done: true },
|
||||
{ title: "Build something cool", done: false },
|
||||
]);
|
||||
};
|
||||
```
|
||||
|
||||
## Framework & Runtime configuration
|
||||
|
||||
Depending on which framework or runtime you're using to run bknd, the configuration object will extend the `BkndConfig` type with additional properties.
|
||||
|
||||
@@ -167,10 +167,6 @@ export default {
|
||||
|
||||
### `cloudflareImageOptimization`
|
||||
|
||||
<Callout type="info">
|
||||
This plugin doesn't work on the development server, or on workers deployed with a `workers.dev` subdomain. It requires [Cloudflare Image transformations to be enabled](https://developers.cloudflare.com/images/get-started/#enable-transformations-on-your-zone) on your zone.
|
||||
</Callout>
|
||||
|
||||
A plugin that add Cloudflare Image Optimization to your app's media storage.
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"./extending/config",
|
||||
"./extending/events",
|
||||
"./extending/plugins",
|
||||
"./extending/admin",
|
||||
"---Integration---",
|
||||
"./integration/introduction",
|
||||
"./integration/(frameworks)/",
|
||||
|
||||
@@ -4,12 +4,6 @@ description: "Easily implement reliable authentication strategies."
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
Authentication is essential for securing applications, and **bknd** provides a straightforward approach to implementing robust strategies.
|
||||
|
||||
### **Core Features**
|
||||
|
||||
@@ -4,12 +4,6 @@ description: "Define, query, and control your data with ease."
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
Data is the lifeblood of any application, and **bknd** provides the tools to handle it effortlessly. From defining entities to executing complex queries, bknd offers a developer-friendly, intuitive, and powerful data management experience.
|
||||
|
||||
### **Define Your Data**
|
||||
|
||||
@@ -4,12 +4,6 @@ description: "Effortlessly manage and serve all your media files."
|
||||
tags: ["documentation"]
|
||||
---
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
**bknd** provides a flexible and efficient way to handle media files, making it easy to upload, manage, and deliver content across various platforms.
|
||||
|
||||
### **Core Features**
|
||||
@@ -42,13 +36,13 @@ There are two ways to enable S3 or S3 compatible storage in bknd.
|
||||
|
||||
2. Enable programmatically.
|
||||
|
||||
To enable using a code-first approach, create corresponding configuration using the `config` option in your `bknd.config.ts` file.
|
||||
To enable using a code-first approach, create an [Initial Structure](/usage/database#initial-structure) using the `initialConfig` option in your `bknd.config.ts` file.
|
||||
|
||||
```typescript title="bknd.config.ts"
|
||||
import type { BkndConfig } from "bknd/adapter";
|
||||
|
||||
export default {
|
||||
config: {
|
||||
initialConfig: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
@@ -76,7 +70,7 @@ import type { BkndConfig } from "bknd/adapter";
|
||||
const local = registerLocalMediaAdapter();
|
||||
|
||||
export default {
|
||||
config: {
|
||||
initialConfig: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: local({
|
||||
|
||||
@@ -6,12 +6,6 @@ tags: ["documentation"]
|
||||
|
||||
import { Icon } from "@iconify/react";
|
||||
|
||||
<Callout type="info" title="The documentation is currently a work in progress">
|
||||
Check back soon — or stay updated on our progress on
|
||||
[GitHub](https://github.com/bknd-io/bknd) and join the conversation in
|
||||
[Discord](https://discord.gg/952SFk8Tb8).
|
||||
</Callout>
|
||||
|
||||
This backend system focuses on 4 essential building blocks which can be tightly connected:
|
||||
Data, Auth, Media and Flows.
|
||||
The main idea is to supply all baseline functionality required in order to accomplish complex
|
||||
|
||||
@@ -274,7 +274,6 @@ sync database
|
||||
Options:
|
||||
-c, --config <config> config file
|
||||
--db-url <db> database url, can be any valid sqlite url
|
||||
--seed perform seeding operations
|
||||
--force perform database syncing operations
|
||||
--drop include destructive DDL operations
|
||||
--out <file> output file
|
||||
|
||||
@@ -314,11 +314,15 @@ const connection = new CustomConnection();
|
||||
const app = createApp({ connection });
|
||||
```
|
||||
|
||||
## Data Structure
|
||||
## Initial Structure
|
||||
|
||||
To provide a database structure, you can pass `config` to the creation of an app. In [`db` mode](/usage/introduction#ui-only-mode), the data structure is only respected if the database is empty. If you made updates, ensure to delete the database first, or perform updates through the Admin UI.
|
||||
To provide an initial database structure, you can pass `initialConfig` to the creation of an app. This will only be used if there isn't an existing configuration found in the database given. Here is a quick example:
|
||||
|
||||
Here is a quick example:
|
||||
<Callout type="info">
|
||||
The initial structure is only respected if the database is empty! If you made
|
||||
updates, ensure to delete the database first, or perform updates through the
|
||||
Admin UI.
|
||||
</Callout>
|
||||
|
||||
```typescript
|
||||
import { createApp, em, entity, text, number } from "bknd";
|
||||
@@ -363,7 +367,10 @@ type Database = (typeof schema)["DB"];
|
||||
|
||||
// pass the schema to the app
|
||||
const app = createApp({
|
||||
config: {
|
||||
connection: {
|
||||
/* ... */
|
||||
},
|
||||
initialConfig: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
});
|
||||
@@ -466,7 +473,7 @@ const schema = em(
|
||||
|
||||
To get type completion, there are two options:
|
||||
|
||||
1. Use the CLI to [generate the types](/usage/cli#generating-types-types) (recommended)
|
||||
1. Use the CLI to [generate the types](/usage/cli#generating-types-types)
|
||||
2. If you have an initial structure created with the prototype functions, you can extend the `DB` interface with your own schema.
|
||||
|
||||
All entity related functions use the types defined in `DB` from `bknd`. To get type completion, you can extend that interface with your own schema:
|
||||
@@ -475,14 +482,18 @@ All entity related functions use the types defined in `DB` from `bknd`. To get t
|
||||
import { em } from "bknd";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
const schema = em({ /* ... */ });
|
||||
const schema = em({
|
||||
/* ... */
|
||||
});
|
||||
|
||||
type Database = (typeof schema)["DB"];
|
||||
declare module "bknd" {
|
||||
interface DB extends Database {}
|
||||
}
|
||||
|
||||
const api = new Api({ /* ... */ });
|
||||
const api = new Api({
|
||||
/* ... */
|
||||
});
|
||||
const { data: posts } = await api.data.readMany("posts", {});
|
||||
// `posts` is now typed as Database["posts"]
|
||||
```
|
||||
@@ -494,13 +505,21 @@ The type completion is available for the API as well as all provided [React hook
|
||||
To seed your database with initial data, you can pass a `seed` function to the configuration. It
|
||||
provides the `ModuleBuildContext` as the first argument.
|
||||
|
||||
<Callout type="info">
|
||||
Note that the seed function will only be executed on app's first boot. If a
|
||||
configuration already exists in the database, it will not be executed.
|
||||
</Callout>
|
||||
|
||||
```typescript
|
||||
import { createApp, type ModuleBuildContext } from "bknd";
|
||||
|
||||
const app = createApp({
|
||||
connection: { /* ... */ },
|
||||
config: { /* ... */ },
|
||||
connection: {
|
||||
/* ... */
|
||||
},
|
||||
initialConfig: {
|
||||
/* ... */
|
||||
},
|
||||
options: {
|
||||
seed: async (ctx: ModuleBuildContext) => {
|
||||
await ctx.em.mutator("posts").insertMany([
|
||||
@@ -511,13 +530,3 @@ const app = createApp({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Note that in [`db` mode](/usage/introduction#ui-only-mode), the seed function will only be executed on app's first boot. If a configuration already exists in the database, it will not be executed.
|
||||
|
||||
In [`code` mode](/usage/introduction#code-only-mode), the seed function will not be automatically executed. You can manually execute it by running the following command:
|
||||
|
||||
```bash
|
||||
npx bknd sync --seed --force
|
||||
```
|
||||
|
||||
See the [sync command](/usage/cli#syncing-the-database-sync) documentation for more details.
|
||||
|
||||
@@ -184,4 +184,197 @@ To keep your config, secrets and types in sync, you can either use the CLI or th
|
||||
|
||||
|
||||
|
||||
## Configuration (`BkndConfig`)
|
||||
|
||||
The `BkndConfig` type is the main configuration object for the `createApp` function. It has
|
||||
the following properties:
|
||||
|
||||
```typescript
|
||||
import type { App, InitialModuleConfigs, ModuleBuildContext, Connection, MaybePromise } from "bknd";
|
||||
import type { Config } from "@libsql/client";
|
||||
|
||||
type AppPlugin = (app: App) => Promise<void> | void;
|
||||
type ManagerOptions = {
|
||||
basePath?: string;
|
||||
trustFetched?: boolean;
|
||||
onFirstBoot?: () => Promise<void>;
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
};
|
||||
|
||||
type BkndConfig<Args = any> = {
|
||||
connection?: Connection | Config;
|
||||
config?: InitialModuleConfigs;
|
||||
options?: {
|
||||
plugins?: AppPlugin[];
|
||||
manager?: ManagerOptions;
|
||||
};
|
||||
app?: BkndConfig<Args> | ((args: Args) => MaybePromise<BkndConfig<Args>>);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app?: App) => Promise<void>;
|
||||
buildConfig?: {
|
||||
sync?: boolean;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### `connection`
|
||||
|
||||
The `connection` property is the main connection object to the database. It can be either an object with libsql config or the actual `Connection` class.
|
||||
|
||||
```ts
|
||||
// uses the default SQLite connection depending on the runtime
|
||||
const connection = { url: "<url>" };
|
||||
|
||||
// the same as above, but more explicit
|
||||
import { sqlite } from "bknd/adapter/sqlite";
|
||||
const connection = sqlite({ url: "<url>" });
|
||||
|
||||
// Node.js SQLite, default on Node.js
|
||||
import { nodeSqlite } from "bknd/adapter/node";
|
||||
const connection = nodeSqlite({ url: "<url>" });
|
||||
|
||||
// Bun SQLite, default on Bun
|
||||
import { bunSqlite } from "bknd/adapter/bun";
|
||||
const connection = bunSqlite({ url: "<url>" });
|
||||
|
||||
// LibSQL, default on Cloudflare
|
||||
import { libsql } from "bknd";
|
||||
const connection = libsql({ url: "<url>" });
|
||||
```
|
||||
|
||||
See a full list of available connections in the [Database](/usage/database) section. Alternatively, you can pass an instance of a `Connection` class directly, see [Custom Connection](/usage/database#custom-connection) as a reference.
|
||||
|
||||
If the connection object is omitted, the app will try to use an in-memory database.
|
||||
|
||||
### `config`
|
||||
|
||||
As [initial configuration](/usage/database#initial-structure), you can either pass a partial configuration object or a complete one
|
||||
with a version number. The version number is used to automatically migrate the configuration up
|
||||
to the latest version upon boot. The default configuration looks like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"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": {},
|
||||
"relations": {},
|
||||
"indices": {}
|
||||
},
|
||||
"auth": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/auth",
|
||||
"entity_name": "users",
|
||||
"allow_register": true,
|
||||
"jwt": {
|
||||
"secret": "",
|
||||
"alg": "HS256",
|
||||
"fields": ["id", "email", "role"]
|
||||
},
|
||||
"cookie": {
|
||||
"path": "/",
|
||||
"sameSite": "lax",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"expires": 604800,
|
||||
"renew": true,
|
||||
"pathSuccess": "/",
|
||||
"pathLoggedOut": "/"
|
||||
},
|
||||
"strategies": {
|
||||
"password": {
|
||||
"type": "password",
|
||||
"config": {
|
||||
"hashing": "sha256"
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {}
|
||||
},
|
||||
"media": {
|
||||
"enabled": false,
|
||||
"basepath": "/api/media",
|
||||
"entity_name": "media",
|
||||
"storage": {}
|
||||
},
|
||||
"flows": {
|
||||
"basepath": "/api/flows",
|
||||
"flows": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can use the [CLI](/usage/cli/#getting-the-configuration-config) to get the default configuration:
|
||||
|
||||
```sh
|
||||
npx bknd config --default --pretty
|
||||
```
|
||||
|
||||
To validate your configuration against a JSON schema, you can also dump the schema using the CLI:
|
||||
|
||||
```sh
|
||||
npx bknd schema
|
||||
```
|
||||
|
||||
To create an initial data structure, you can use helpers [described here](/usage/database#initial-structure).
|
||||
|
||||
### `options.plugins`
|
||||
|
||||
The `plugins` property is an array of functions that are called after the app has been built,
|
||||
but before its event is emitted. This is useful for adding custom routes or other functionality.
|
||||
A simple plugin that adds a custom route looks like this:
|
||||
|
||||
```ts
|
||||
import type { AppPlugin } from "bknd";
|
||||
|
||||
export const myPlugin: AppPlugin = (app) => {
|
||||
app.server.get("/hello", (c) => c.json({ hello: "world" }));
|
||||
};
|
||||
```
|
||||
|
||||
Since each plugin has full access to the `app` instance, it can add routes, modify the database
|
||||
structure, add custom middlewares, respond to or add events, etc. Plugins are very powerful, so
|
||||
make sure to only run trusted ones.
|
||||
|
||||
### `options.seed`
|
||||
|
||||
<Callout type="info">
|
||||
The seed function will only be executed on app's first boot in `"db"` mode. If a configuration already exists in the database, or in `"code"` mode, it will not be executed.
|
||||
</Callout>
|
||||
|
||||
The `seed` property is a function that is called when the app is booted for the first time. It is used to seed the database with initial data. The function is passed a `ModuleBuildContext` object:
|
||||
|
||||
```ts
|
||||
type ModuleBuildContext = {
|
||||
connection: Connection;
|
||||
server: Hono;
|
||||
em: EntityManager;
|
||||
emgr: EventManager;
|
||||
guard: Guard;
|
||||
};
|
||||
|
||||
const seed = async (ctx: ModuleBuildContext) => {
|
||||
// seed the database
|
||||
await ctx.em.mutator("todos").insertMany([
|
||||
{ title: "Learn bknd", done: true },
|
||||
{ title: "Build something cool", done: false },
|
||||
]);
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ Once enabled, you can access the MCP UI at `/mcp`, or choose "MCP" from the top
|
||||
|
||||
## Enable MCP
|
||||
|
||||
If you're using a `config`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`.
|
||||
If you're using `initialConfig`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`.
|
||||
|
||||
```typescript
|
||||
import type { BkndConfig } from "bknd";
|
||||
|
||||
export default {
|
||||
config: {
|
||||
initialConfig: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ const config = {
|
||||
connection: {
|
||||
url: "file:data.db",
|
||||
},
|
||||
config: {
|
||||
initialConfig: {
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
|
||||
@@ -87,7 +87,7 @@ async function setup(opts?: {
|
||||
const app = App.create({
|
||||
connection,
|
||||
// an initial config is only applied if the database is empty
|
||||
config: {
|
||||
initialConfig: {
|
||||
data: schema.toJSON(),
|
||||
},
|
||||
options: {
|
||||
|
||||
Reference in New Issue
Block a user