Compare commits

..

1 Commits

Author SHA1 Message Date
dswbx 06d7558c3c feat: batch schema manager statements
run all schema modification queries in a single batch/transaction, to enable automatic rollbacks, and to stay within cloudflare's subrequest limits in free plan.
2025-09-24 14:48:45 +02:00
53 changed files with 390 additions and 1166 deletions
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
hello
-127
View File
@@ -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();
});
});
+36 -1
View File
@@ -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 */
});
});
+1 -35
View File
@@ -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();
});
});
-37
View File
@@ -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
View File
@@ -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({
-1
View File
@@ -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,
+2 -2
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.18.0",
"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",
@@ -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:";
+1 -1
View File
@@ -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";
+1 -23
View File
@@ -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",
return new Response(content, {
status: 200,
headers: {
"Content-Type": mimeType || "application/octet-stream",
"Content-Length": content.length.toString(),
},
});
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,
});
}
} catch (error) {
// Handle file reading errors
return new Response("", { status: 404 });
-3
View File
@@ -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 -1
View File
@@ -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 {
+1 -16
View File
@@ -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);
+1 -1
View File
@@ -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");
@@ -55,8 +55,7 @@ export class SqliteIntrospector extends BaseIntrospector {
)) FROM pragma_index_info(i.name) ii)
)) FROM pragma_index_list(m.name) i
LEFT JOIN sqlite_master im ON im.name = i.name
AND im.type = 'index'
WHERE i.name not like 'sqlite_%'
AND im.type = 'index'
) AS indices
FROM sqlite_master m
WHERE m.type IN ('table', 'view')
+1 -6
View File
@@ -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>) {
+1 -2
View File
@@ -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",
};
}
}
+17 -30
View File
@@ -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;
if (qbs.length > 0) {
statements.push(
...qbs.map((qb) => {
const { sql, parameters } = qb.compile();
return { sql, parameters };
}),
);
// iterate through built qbs
// @todo: run in batches
for (const qb of qbs) {
const { sql, parameters } = qb.compile();
statements.push({ 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();
} catch (e) {
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
}
}
try {
await this.em.connection.executeQueries(...qbs);
} catch (e) {
throw new Error(`Failed to execute batch: ${String(e)}`);
}
}
-25
View File
@@ -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() {
-4
View File
@@ -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;
}
+8 -15
View File
@@ -71,29 +71,22 @@ export class Storage implements EmitsEvents {
let info: FileUploadPayload = {
name,
meta: isFile(file)
? {
size: file.size,
type: file.type,
}
: {
size: 0,
type: "application/octet-stream",
},
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",
+6 -7
View File
@@ -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`],
+4 -4
View File
@@ -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) {
+27 -29
View File
@@ -8,33 +8,7 @@ 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";
export type BkndAdminConfig = {
/**
* Base path of the Admin UI
* @default `/`
*/
basepath?: string;
/**
* Path to return to when clicking the logo
* @default `/`
*/
logo_return_path?: string;
/**
* Theme of the Admin UI
* @default `system`
*/
theme?: AppTheme;
/**
* Entities configuration like headers, footers, actions, field renders, etc.
*/
entities?: BkndAdminEntitiesOptions;
/**
* App shell configuration like user menu actions.
*/
appShell?: BkndAdminAppShellOptions;
};
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "ui/options";
export type BkndAdminProps = {
/**
@@ -42,13 +16,37 @@ export type BkndAdminProps = {
*/
baseUrl?: string;
/**
* Whether to wrap Admin in a `<ClientProvider />`
* Whether to wrap Admin in a <ClientProvider />
*/
withProvider?: boolean | ClientProviderProps;
/**
* Admin UI customization options
*/
config?: BkndAdminConfig;
config?: {
/**
* Base path of the Admin UI
* @default `/`
*/
basepath?: string;
/**
* Path to return to when clicking the logo
* @default `/`
*/
logo_return_path?: string;
/**
* Theme of the Admin UI
* @default `system`
*/
theme?: AppTheme;
/**
* Entities configuration like headers, footers, actions, field renders, etc.
*/
entities?: BkndAdminEntitiesOptions;
/**
* App shell configuration like user menu actions.
*/
appShell?: BkndAdminAppShellOptions;
};
children?: ReactNode;
};
+6 -21
View File
@@ -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 (
+6 -74
View File
@@ -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)),
})),
}),
),
);
+1 -1
View File
@@ -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";
+2 -66
View File
@@ -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>
@@ -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.
@@ -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.
Here is a quick example:
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:
<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,
+1 -1
View File
@@ -9,7 +9,7 @@ const config = {
connection: {
url: "file:data.db",
},
config: {
initialConfig: {
media: {
enabled: true,
adapter: {
+1 -1
View File
@@ -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: {