Merge pull request #250 from bknd-io/release/0.18

Release 0.18
This commit is contained in:
dswbx
2025-10-01 09:07:18 +02:00
committed by GitHub
214 changed files with 5548 additions and 1852 deletions

View File

@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.2.19" bun-version: "1.2.22"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app

View File

@@ -1,12 +1,15 @@
import { afterEach, describe, test, expect } from "bun:test"; import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test";
import { App, createApp } from "core/test/utils"; import { App, createApp } from "core/test/utils";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
import { Hono } from "hono"; import { Hono } from "hono";
import * as proto from "../src/data/prototype"; import * as proto from "../src/data/prototype";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup); afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("App tests", async () => { describe("App tests", async () => {
test("boots and pongs", async () => { test("boots and pongs", async () => {
@@ -19,7 +22,7 @@ describe("App tests", async () => {
test("plugins", async () => { test("plugins", async () => {
const called: string[] = []; const called: string[] = [];
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
}, },

Binary file not shown.

View File

@@ -0,0 +1 @@
hello

View File

@@ -1,4 +1,4 @@
import { expect, describe, it, beforeAll, afterAll } from "bun:test"; import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter"; import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
@@ -19,50 +19,39 @@ describe("adapter", () => {
expect( expect(
omitKeys( omitKeys(
await adapter.makeConfig( await adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) }, { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } }, { env: { TEST: "test" } },
), ),
["connection"], ["connection"],
), ),
).toEqual({ ).toEqual({
initialConfig: { server: { cors: { origin: "test" } } }, config: { server: { cors: { origin: "test" } } },
}); });
}); });
/* it.only("...", async () => { it("allows all properties in app function", async () => {
const app = await adapter.createAdapterApp(); const called = mock(() => null);
}); */ const config = await adapter.makeConfig(
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
{ {
initialConfig: { server: { cors: { origin: "random" } } }, app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: {
mode: "db",
},
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
}, },
undefined, { foo: "bar" },
{ id },
); );
const second = await adapter.createAdapterApp(); expect(config.connection).toEqual({ url: "test" });
const third = await adapter.createAdapterApp(undefined, undefined, { id }); expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(config.options).toEqual({ mode: "db" });
await first.build(); await config.onBuilt?.(null as any);
await second.build(); expect(called).toHaveBeenCalled();
await third.build();
expect(first.toJSON().server.cors.origin).toEqual("random");
expect(first).toBe(third);
expect(first).not.toBe(second);
expect(second).not.toBe(third);
expect(second.toJSON().server.cors.origin).toEqual("*");
// recreate the first one
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
await first2.build();
expect(first2).not.toBe(first);
expect(first2).not.toBe(third);
expect(first2).not.toBe(second);
expect(first2.toJSON().server.cors.origin).toEqual("*");
}); });
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {

View File

@@ -42,7 +42,6 @@ describe("Api", async () => {
expect(api.isAuthVerified()).toBe(false); expect(api.isAuthVerified()).toBe(false);
const params = api.getParams(); const params = api.getParams();
console.log(params);
expect(params.token).toBe(token); expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie"); expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com"); expect(params.host).toBe("http://example.com");

View File

@@ -1,9 +1,23 @@
import { describe, expect, mock, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src"; import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils"; import { App, createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype"; import * as proto from "data/prototype";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("App", () => { describe("App", () => {
test("use db mode by default", async () => {
const app = createApp();
await app.build();
expect(app.mode).toBe("db");
expect(app.isReadOnly()).toBe(false);
expect(app.modules instanceof DbModuleManager).toBe(true);
});
test("seed includes ctx and app", async () => { test("seed includes ctx and app", async () => {
const called = mock(() => null); const called = mock(() => null);
await createApp({ await createApp({
@@ -29,7 +43,7 @@ describe("App", () => {
expect(called).toHaveBeenCalled(); expect(called).toHaveBeenCalled();
const app = createApp({ const app = createApp({
initialConfig: { config: {
data: proto data: proto
.em({ .em({
todos: proto.entity("todos", { todos: proto.entity("todos", {
@@ -139,7 +153,7 @@ describe("App", () => {
test("getMcpClient", async () => { test("getMcpClient", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,

View File

@@ -16,6 +16,7 @@ describe("AppServer", () => {
mcp: { mcp: {
enabled: false, enabled: false,
path: "/api/system/mcp", path: "/api/system/mcp",
logLevel: "warning",
}, },
}); });
} }
@@ -38,6 +39,7 @@ describe("AppServer", () => {
mcp: { mcp: {
enabled: false, enabled: false,
path: "/api/system/mcp", path: "/api/system/mcp",
logLevel: "warning",
}, },
}); });
} }

View File

@@ -0,0 +1,127 @@
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();
});
});

View File

@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
let server: McpServer; let server: McpServer;
beforeEach(async () => { beforeEach(async () => {
app = createApp({ app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
jwt: { jwt: {
@@ -44,6 +44,7 @@ describe("mcp auth", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {

View File

@@ -1,14 +1,18 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { registries } from "index"; import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("mcp", () => { describe("mcp", () => {
it("should have tools", async () => { it("should have tools", async () => {
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
}, },
@@ -30,6 +34,11 @@ describe("mcp", () => {
}); });
await app.build(); await app.build();
// expect mcp to not be loaded yet
expect(app.mcp).toBeNull();
// after first request, mcp should be loaded
await app.getMcpClient().listTools();
expect(app.mcp?.tools.length).toBeGreaterThan(0); expect(app.mcp?.tools.length).toBeGreaterThan(0);
}); });
}); });

View File

@@ -41,7 +41,7 @@ describe("mcp data", async () => {
beforeEach(async () => { beforeEach(async () => {
const time = performance.now(); const time = performance.now();
app = createApp({ app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
@@ -50,6 +50,7 @@ describe("mcp data", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {

View File

@@ -21,7 +21,7 @@ describe("mcp media", async () => {
beforeEach(async () => { beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
app = createApp({ app = createApp({
initialConfig: { config: {
media: { media: {
enabled: true, enabled: true,
adapter: { adapter: {
@@ -39,6 +39,7 @@ describe("mcp media", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {

View File

@@ -1,6 +1,10 @@
import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test"; import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils"; import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
/** /**
* - [x] config_server_get * - [x] config_server_get
@@ -11,7 +15,7 @@ describe("mcp system", async () => {
let server: McpServer; let server: McpServer;
beforeAll(async () => { beforeAll(async () => {
app = createApp({ app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
@@ -20,6 +24,7 @@ describe("mcp system", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
}); });

View File

@@ -14,7 +14,7 @@ describe("mcp system", async () => {
let server: McpServer; let server: McpServer;
beforeAll(async () => { beforeAll(async () => {
app = createApp({ app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
@@ -23,6 +23,7 @@ describe("mcp system", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
}); });

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { syncConfig } from "plugins/dev/sync-config.plugin";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("syncConfig", () => {
it("should only sync if enabled", async () => {
const called = mock(() => null);
const app = createApp();
await app.build();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: false,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: true,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(1);
});
it("should respect secrets", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await app.build();
await syncConfig({
write: async (config) => {
called();
expect(config.auth.jwt.secret).toBe("test");
},
enabled: true,
includeSecrets: true,
includeFirstBoot: true,
})(app).onBuilt?.();
await syncConfig({
write: async (config) => {
called();
// it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON()
// but it's required to get the app running
expect(config.auth.jwt.secret).toBe("");
},
enabled: true,
includeSecrets: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { registries } from "../../src"; import { registries } from "../../src";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype"; import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("repros", async () => { describe("repros", async () => {
/** /**
@@ -88,7 +92,7 @@ describe("repros", async () => {
fns.relation(schema.product_likes).manyToOne(schema.users); fns.relation(schema.product_likes).manyToOne(schema.users);
}, },
); );
const app = createApp({ initialConfig: { data: schema.toJSON() } }); const app = createApp({ config: { data: schema.toJSON() } });
await app.build(); await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any; const info = (await (await app.server.request("/api/data/info/products")).json()) as any;

View File

@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events"; import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);

View File

@@ -248,7 +248,7 @@ describe("Core Utils", async () => {
expect(utils.getContentName(request)).toBe(name); expect(utils.getContentName(request)).toBe(name);
}); });
test.only("detectImageDimensions", async () => { test("detectImageDimensions", async () => {
// wrong // wrong
// @ts-expect-error // @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow(); expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
@@ -267,12 +267,12 @@ describe("Core Utils", async () => {
}); });
describe("dates", () => { describe("dates", () => {
test.only("formats local time", () => { test("formats local time", () => {
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
console.log(utils.datetimeStringUTC(new Date())); /*console.log(utils.datetimeStringUTC(new Date()));
console.log(utils.datetimeStringUTC()); console.log(utils.datetimeStringUTC());
console.log(new Date()); console.log(new Date());
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */
}); });
}); });
}); });

View File

@@ -5,7 +5,8 @@ import { parse } from "core/utils/schema";
import { DataController } from "../../src/data/api/DataController"; import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema"; import { dataConfigSchema } from "../../src/data/data-schema";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult"; import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult"; import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
import { Entity, EntityManager, type EntityData } from "data/entities"; import { Entity, EntityManager, type EntityData } from "data/entities";
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
import { ManyToOneRelation } from "data/relations"; import { ManyToOneRelation } from "data/relations";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"])); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {}); const dataConfig = parse(dataConfigSchema, {});

View File

@@ -1,12 +1,15 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities"; import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations"; import { ManyToOneRelation } from "data/relations";
import { TextField } from "data/fields"; import { TextField } from "data/fields";
import { JoinBuilder } from "data/entities/query/JoinBuilder"; import { JoinBuilder } from "data/entities/query/JoinBuilder";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] JoinBuilder", async () => { describe("[data] JoinBuilder", async () => {
test("missing relation", async () => { test("missing relation", async () => {

View File

@@ -9,13 +9,14 @@ import {
} from "data/relations"; } from "data/relations";
import { NumberField, TextField } from "data/fields"; import { NumberField, TextField } from "data/fields";
import * as proto from "data/prototype"; import * as proto from "data/prototype";
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper"; import { getDummyConnection } from "../../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { MutatorEvents } from "data/events"; import { MutatorEvents } from "data/events";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"])); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => { describe("[data] Mutator (base)", async () => {

View File

@@ -1,4 +1,4 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Kysely, Transaction } from "kysely"; import type { Kysely, Transaction } from "kysely";
import { TextField } from "data/fields"; import { TextField } from "data/fields";
import { em as $em, entity as $entity, text as $text } from "data/prototype"; import { em as $em, entity as $entity, text as $text } from "data/prototype";
@@ -6,11 +6,13 @@ import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations"; import { ManyToOneRelation } from "data/relations";
import { RepositoryEvents } from "data/events"; import { RepositoryEvents } from "data/events";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
type E = Kysely<any> | Transaction<any>; type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
async function sleep(ms: number) { async function sleep(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities"; import { Entity, EntityManager } from "data/entities";
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations"; import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
import { TextField } from "data/fields"; import { TextField } from "data/fields";
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
import { WithBuilder } from "data/entities/query/WithBuilder"; import { WithBuilder } from "data/entities/query/WithBuilder";
import { schemaToEm } from "../../helper"; import { schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();

View File

@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
expect(index.name).toEqual("idx_test_name"); expect(index.name).toEqual("idx_test_name");
expect(index.unique).toEqual(false); expect(index.unique).toEqual(false);
}); });
test("it fails on non-unique", async () => {
const field = new TestField("name", { required: false });
expect(() => new EntityIndex(entity, [field], true)).toThrowError();
expect(() => new EntityIndex(entity, [field])).toBeDefined();
});
}); });

View File

@@ -4,8 +4,10 @@ import {
type BaseRelationConfig, type BaseRelationConfig,
EntityRelation, EntityRelation,
EntityRelationAnchor, EntityRelationAnchor,
ManyToManyRelation,
RelationTypes, RelationTypes,
} from "data/relations"; } from "data/relations";
import * as proto from "data/prototype";
class TestEntityRelation extends EntityRelation { class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) { constructor(config?: BaseRelationConfig) {
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
const relation2 = new TestEntityRelation({ required: true }); const relation2 = new TestEntityRelation({ required: true });
expect(relation2.required).toBe(true); expect(relation2.required).toBe(true);
}); });
it("correctly produces the relation name", async () => {
const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"));
expect(relation.getName()).not.toContain(",");
expect(relation.getName()).toBe("mn_apps_organizations");
const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), {
connectionTableMappedName: "appOrganizations",
});
expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations");
});
}); });

View File

@@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" }; return { url: "http://127.0.0.1:8080" };
} }
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
const _oldConsoles = {
debug: console.debug,
log: console.log,
warn: console.warn,
error: console.error,
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}
export function compileQb(qb: SelectQueryBuilder<any, any, any>) { export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
return { sql, parameters }; return { sql, parameters };
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) { export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
console.log("$", sqlFormat(sql), "\n[params]", parameters); console.info("$", sqlFormat(sql), "\n[params]", parameters);
} }
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> { export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {

View File

@@ -1,12 +1,9 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares"; import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -66,9 +63,10 @@ const configs = {
}; };
function createAuthApp() { function createAuthApp() {
const { dummyConnection } = getDummyConnection();
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
initialConfig: { config: {
auth: configs.auth, auth: configs.auth,
}, },
}); });
@@ -151,8 +149,8 @@ describe("integration auth", () => {
const { data: users } = await app.em.repository("users").findMany(); const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2); expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email); expect(users[0]?.email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email); expect(users[1]?.email).toBe(configs.users.admin.email);
}); });
it("should log you in with API", async () => { it("should log you in with API", async () => {
@@ -223,7 +221,7 @@ describe("integration auth", () => {
app.server.get("/get", auth(), async (c) => { app.server.get("/get", auth(), async (c) => {
return c.json({ return c.json({
user: c.get("auth").user ?? null, user: c.get("auth")?.user ?? null,
}); });
}); });
app.server.get("/wait", auth(), async (c) => { app.server.get("/wait", auth(), async (c) => {
@@ -242,7 +240,7 @@ describe("integration auth", () => {
{ {
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
const res = await app.server.request("/get"); const res = await app.server.request("/get");
const data = await res.json(); const data = (await res.json()) as any;
expect(data.user).toBe(null); expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any }); expect(await $fns.me()).toEqual({ user: null as any });
} }

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { Api } from "../../src/Api"; import { Api } from "../../src/Api";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("integration config", () => { describe("integration config", () => {
it("should create an entity", async () => { it("should create an entity", async () => {

View File

@@ -6,17 +6,20 @@ import { createApp } from "core/test/utils";
import { mergeObject, randomString } from "../../src/core/utils"; import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema"; import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => { beforeAll(() => {
//disableConsoleLog();
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
}); });
afterAll(enableConsoleLog);
const path = `${assetsPath}/image.png`; const path = `${assetsPath}/image.png`;
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) { async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({ const app = createApp({
initialConfig: { config: {
media: mergeObject( media: mergeObject(
{ {
enabled: true, enabled: true,
@@ -40,9 +43,6 @@ function makeName(ext: string) {
return randomString(10) + "." + ext; return randomString(10) + "." + ext;
} }
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => { describe("MediaController", () => {
test("accepts direct", async () => { test("accepts direct", async () => {
const app = await makeApp(); const app = await makeApp();
@@ -94,4 +94,38 @@ describe("MediaController", () => {
expect(res.status).toBe(413); expect(res.status).toBe(413);
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false); 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();
});
}); });

View File

@@ -71,6 +71,8 @@ describe("media/mime-types", () => {
["application/zip", "zip"], ["application/zip", "zip"],
["text/tab-separated-values", "tsv"], ["text/tab-separated-values", "tsv"],
["application/zip", "zip"], ["application/zip", "zip"],
["application/pdf", "pdf"],
["audio/mpeg", "mp3"],
] as const; ] as const;
for (const [mime, ext] of tests) { for (const [mime, ext] of tests) {
@@ -88,6 +90,9 @@ describe("media/mime-types", () => {
["image.jpeg", "jpeg"], ["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"],
["file.pdf", "pdf"],
["file.mp3", "mp3"],
["robots.txt", "txt"],
] as const; ] as const;
for (const [filename, ext] of tests) { for (const [filename, ext] of tests) {
@@ -102,4 +107,36 @@ describe("media/mime-types", () => {
const [, ext] = getRandomizedFilename(file).split("."); const [, ext] = getRandomizedFilename(file).split(".");
expect(ext).toBe("jpg"); 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");
}
});
}); });

View File

@@ -3,11 +3,14 @@ import { createApp } from "core/test/utils";
import { AuthController } from "../../src/auth/api/AuthController"; import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "data/prototype"; import { em, entity, make, text } from "data/prototype";
import { AppAuth, type ModuleBuildContext } from "modules"; import { AppAuth, type ModuleBuildContext } from "modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppAuth", () => { describe("AppAuth", () => {
test.only("...", () => { test.skip("...", () => {
const auth = new AppAuth({}); const auth = new AppAuth({});
console.log(auth.toJSON()); console.log(auth.toJSON());
console.log(auth.config); console.log(auth.config);
@@ -147,7 +150,7 @@ describe("AppAuth", () => {
test("registers auth middleware for bknd routes only", async () => { test("registers auth middleware for bknd routes only", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
jwt: { jwt: {
@@ -177,7 +180,7 @@ describe("AppAuth", () => {
test("should allow additional user fields", async () => { test("should allow additional user fields", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
entity_name: "users", entity_name: "users",
enabled: true, enabled: true,
@@ -201,7 +204,7 @@ describe("AppAuth", () => {
test("ensure user field configs is always correct", async () => { test("ensure user field configs is always correct", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
}, },

View File

@@ -7,7 +7,7 @@ import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => { describe("AppMedia", () => {
test.only("...", () => { test.skip("...", () => {
const media = new AppMedia(); const media = new AppMedia();
console.log(media.toJSON()); console.log(media.toJSON());
}); });
@@ -18,7 +18,7 @@ describe("AppMedia", () => {
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
const app = createApp({ const app = createApp({
initialConfig: { config: {
media: { media: {
entity_name: "media", entity_name: "media",
enabled: true, enabled: true,

View File

@@ -0,0 +1,76 @@
import { it, expect, describe } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations";
describe("DbModuleManager", () => {
it("should extract secrets", async () => {
const { dummyConnection } = getDummyConnection();
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
await m.save();
});
it("should work with initial secrets", async () => {
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "test",
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
const getSecrets = () =>
db
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "secrets")
.executeTakeFirst()
.then((r) => r?.json);
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// also after rebuild
await m.build();
await m.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// and ignore if already present
const m2 = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "something completely different",
},
});
await m2.build();
await m2.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
});
});

View File

@@ -1,14 +1,19 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager"; import { getDefaultConfig } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations"; import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { s, stripMark } from "core/utils/schema"; import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype"; import { entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
test("s1: no config, no build", async () => { test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -133,7 +138,7 @@ describe("ModuleManager", async () => {
const db = c2.dummyConnection.kysely; const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, { const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }, initial: { version: version - 1, ...json } as any,
}); });
await mm2.syncConfigTable(); await mm2.syncConfigTable();
await db await db

View File

@@ -1,14 +1,22 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src"; import { App, type InitialModuleConfigs, createApp } from "/";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper"; import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json"; import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json"; import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json"; import v8_2 from "./samples/v8-2.json";
import v9 from "./samples/v9.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
// app expects migratable config to be present in database // app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs | any) { async function createVersionedApp(
config: InitialModuleConfigs | any,
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version"); if (!("version" in config)) throw new Error("config must have a version");
@@ -34,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
}) })
.execute(); .execute();
if (opts?.beforeCreateApp) {
await opts.beforeCreateApp(db);
}
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
}); });
@@ -41,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
return app; return app;
} }
async function getRawConfig(
app: App,
opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] },
) {
const db = app.em.connection.kysely;
return await db
.selectFrom("__bknd")
.selectAll()
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute();
}
describe("Migrations", () => { describe("Migrations", () => {
/** /**
* updated auth strategies to have "enabled" prop * updated auth strategies to have "enabled" prop
@@ -78,4 +103,30 @@ describe("Migrations", () => {
// @ts-expect-error // @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined(); expect(app.toJSON(true).server.admin).toBeUndefined();
}); });
test("migration from 9 to 10", async () => {
expect(v9.version).toBe(9);
const app = await createVersionedApp(v9);
expect(app.version()).toBeGreaterThan(9);
// @ts-expect-error
expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe(
"^^s3.secret_access_key^^",
);
const [config, secrets] = (await getRawConfig(app, {
version: 10,
types: ["config", "secrets"],
})) as any;
expect(config.json.auth.jwt.secret).toBe("");
expect(config.json.media.adapter.config.access_key).toBe("");
expect(config.json.media.adapter.config.secret_access_key).toBe("");
expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^");
expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^");
expect(secrets.json["media.adapter.config.secret_access_key"]).toBe(
"^^s3.secret_access_key^^",
);
});
}); });

View File

@@ -0,0 +1,612 @@
{
"version": 9,
"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" }
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": { "type": "text", "config": { "required": true } },
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": { "type": "text", "config": { "required": false } },
"size": { "type": "number", "config": { "required": false } },
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": { "type": "text", "config": { "required": false } },
"modified_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"reference": { "type": "text", "config": { "required": false } },
"entity_id": { "type": "number", "config": { "required": false } },
"metadata": { "type": "json", "config": { "required": false } }
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"email": { "type": "text", "config": { "required": true } },
"strategy": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["password"] },
"required": true,
"hidden": ["update", "form"],
"fillable": ["create"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["admin", "guest"] },
"required": false
}
},
"age": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["18-24", "25-34", "35-44", "45-64", "65+"]
},
"required": false
}
},
"height": { "type": "number", "config": { "required": false } },
"gender": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["male", "female"] },
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"avatars": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"identifier": { "type": "text", "config": { "required": false } },
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"started_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"input": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"tryons": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"avatars_id": {
"type": "relation",
"config": {
"label": "Avatars",
"required": false,
"reference": "avatars",
"target": "avatars",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "tryons",
"max_items": 1
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Products",
"required": false,
"reference": "products",
"target": "products",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"enabled": { "type": "boolean", "config": { "required": false } },
"title": { "type": "text", "config": { "required": false } },
"url": { "type": "text", "config": { "required": false } },
"image": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products",
"max_items": 1
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"garment_type": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["auto", "tops", "bottoms", "one-pieces"]
},
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sites": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"origin": {
"type": "text",
"config": {
"pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$",
"required": true
}
},
"name": { "type": "text", "config": { "required": false } },
"active": { "type": "boolean", "config": { "required": false } },
"logo": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "sites",
"max_items": 1
}
},
"instructions": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": { "rows": "2" }
},
"required": false,
"hidden": ["table"]
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sessions": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": { "format": "uuid", "fillable": false, "required": false }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": true }
},
"claimed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"url": { "type": "text", "config": { "required": false } },
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
}
},
"relations": {
"poly_avatars_media_input": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "input" }
},
"poly_avatars_media_output": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "output" }
},
"n1_avatars_users": {
"type": "n:1",
"source": "avatars",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_avatars": {
"type": "n:1",
"source": "tryons",
"target": "avatars",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_users": {
"type": "n:1",
"source": "tryons",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_tryons_media_output": {
"type": "poly",
"source": "tryons",
"target": "media",
"config": { "mappedBy": "output", "targetCardinality": 1 }
},
"poly_products_media_image": {
"type": "poly",
"source": "products",
"target": "media",
"config": { "mappedBy": "image", "targetCardinality": 1 }
},
"n1_tryons_products": {
"type": "n:1",
"source": "tryons",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_sites_media_logo": {
"type": "poly",
"source": "sites",
"target": "media",
"config": { "mappedBy": "logo", "targetCardinality": 1 }
},
"n1_sessions_sites": {
"type": "n:1",
"source": "sessions",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_sessions_users": {
"type": "n:1",
"source": "sessions",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_products_sites": {
"type": "n:1",
"source": "products",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_sites_origin_active": {
"entity": "sites",
"fields": ["origin", "active"],
"unique": false
},
"idx_sites_active": {
"entity": "sites",
"fields": ["active"],
"unique": false
},
"idx_products_url": {
"entity": "products",
"fields": ["url"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "^^jwt.secret^^",
"alg": "HS256",
"expires": 999999999,
"issuer": "issuer",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "none",
"secure": true,
"httpOnly": true,
"expires": 604800,
"partitioned": false,
"renew": true,
"pathSuccess": "/admin",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": { "hashing": "sha256" }
}
},
"guard": { "enabled": false },
"roles": {
"admin": { "implicit_allow": true },
"guest": { "is_default": true }
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": { "body_max_size": 0 },
"adapter": {
"type": "s3",
"config": {
"access_key": "^^s3.access_key^^",
"secret_access_key": "^^s3.secret_access_key^^",
"url": "https://1234.r2.cloudflarestorage.com/bucket-name"
}
}
},
"flows": { "basepath": "/api/flows", "flows": {} }
}

View File

@@ -3,20 +3,25 @@ import c from "picocolors";
import { formatNumber } from "bknd/utils"; import { formatNumber } from "bknd/utils";
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
const deps = Object.keys(pkg.dependencies);
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
if (process.env.DEBUG) { if (process.env.DEBUG) {
await esbuild.build({ const result = await esbuild.build({
entryPoints: ["./src/cli/index.ts"], entryPoints: ["./src/cli/index.ts"],
outdir: "./dist/cli", outdir: "./dist/cli",
platform: "node", platform: "node",
minify: false, minify: true,
format: "esm", format: "esm",
metafile: true,
bundle: true, bundle: true,
external: ["jsonv-ts", "jsonv-ts/*"], external,
define: { define: {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),
}, },
}); });
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
process.exit(0); process.exit(0);
} }
@@ -26,7 +31,7 @@ const result = await Bun.build({
outdir: "./dist/cli", outdir: "./dist/cli",
env: "PUBLIC_*", env: "PUBLIC_*",
minify: true, minify: true,
external: ["jsonv-ts", "jsonv-ts/*"], external,
define: { define: {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),

View File

@@ -252,6 +252,8 @@ async function buildAdapters() {
platform: "neutral", platform: "neutral",
entry: ["src/adapter/index.ts"], entry: ["src/adapter/index.ts"],
outDir: "dist/adapter", outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}), }),
// specific adatpers // specific adatpers
@@ -270,6 +272,7 @@ async function buildAdapters() {
), ),
tsup.build( tsup.build(
baseConfig("cloudflare/proxy", { baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"], entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare", outDir: "dist/adapter/cloudflare",
metafile: false, metafile: false,

View File

@@ -3,7 +3,7 @@ import { createApp } from "bknd/adapter/bun";
async function generate() { async function generate() {
console.info("Generating MCP documentation..."); console.info("Generating MCP documentation...");
const app = await createApp({ const app = await createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,

View File

@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.17.2", "version": "0.18.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -13,7 +13,7 @@
"bugs": { "bugs": {
"url": "https://github.com/bknd-io/bknd/issues" "url": "https://github.com/bknd-io/bknd/issues"
}, },
"packageManager": "bun@1.2.19", "packageManager": "bun@1.2.22",
"engines": { "engines": {
"node": ">=22.13" "node": ">=22.13"
}, },
@@ -30,7 +30,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts", "cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./", "prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md", "postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node", "test:all": "bun run test && bun run test:node",
@@ -40,7 +40,7 @@
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest:coverage": "vitest run --coverage", "test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts", "test:e2e:adapters": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts",
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui", "test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug", "test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report", "test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
@@ -65,7 +65,7 @@
"hono": "4.8.3", "hono": "4.8.3",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.2", "jsonv-ts": "0.8.4",
"kysely": "0.27.6", "kysely": "0.27.6",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
@@ -78,10 +78,10 @@
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.2",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@cloudflare/vitest-pool-workers": "^0.8.38", "@cloudflare/vitest-pool-workers": "^0.9.3",
"@cloudflare/workers-types": "^4.20250606.0", "@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/vite-dev-server": "^0.19.1", "@hono/vite-dev-server": "^0.21.0",
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.9", "@libsql/client": "^0.15.9",
"@mantine/modals": "^7.17.1", "@mantine/modals": "^7.17.1",
@@ -130,7 +130,9 @@
"vite-plugin-circular-dependency": "^0.5.0", "vite-plugin-circular-dependency": "^0.5.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9", "vitest": "^3.0.9",
"wouter": "^3.6.0" "wouter": "^3.6.0",
"wrangler": "^4.37.1",
"miniflare": "^4.20250913.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.14.3" "@hono/node-server": "^1.14.3"

View File

@@ -5,17 +5,18 @@ import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
ModuleManager,
type InitialModuleConfigs, type InitialModuleConfigs,
type ModuleBuildContext,
type ModuleConfigs, type ModuleConfigs,
type ModuleManagerOptions,
type Modules, type Modules,
ModuleManager,
type ModuleBuildContext,
type ModuleManagerOptions,
} from "modules/ModuleManager"; } from "modules/ModuleManager";
import { DbModuleManager } from "modules/db/DbModuleManager";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; import { SystemController } from "modules/server/SystemController";
import type { MaybePromise } from "core/types"; import type { MaybePromise, PartialRec } from "core/types";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers"; import type { IEmailDriver, ICacheDriver } from "core/drivers";
@@ -93,20 +94,23 @@ export type AppOptions = {
email?: IEmailDriver; email?: IEmailDriver;
cache?: ICacheDriver; cache?: ICacheDriver;
}; };
mode?: "db" | "code";
readonly?: boolean;
}; };
export type CreateAppConfig = { export type CreateAppConfig = {
/**
* bla
*/
connection?: Connection | { url: string }; connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs; config?: PartialRec<ModuleConfigs>;
options?: AppOptions; options?: AppOptions;
}; };
export type AppConfig = InitialModuleConfigs; export type AppConfig = { version: number } & ModuleConfigs;
export type LocalApiOptions = Request | ApiOptions; export type LocalApiOptions = Request | ApiOptions;
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> { export class App<
C extends Connection = Connection,
Config extends PartialRec<ModuleConfigs> = PartialRec<ModuleConfigs>,
Options extends AppOptions = AppOptions,
> {
static readonly Events = AppEvents; static readonly Events = AppEvents;
modules: ModuleManager; modules: ModuleManager;
@@ -121,8 +125,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
constructor( constructor(
public connection: C, public connection: C,
_initialConfig?: InitialModuleConfigs, _config?: Config,
private options?: Options, public options?: Options,
) { ) {
this.drivers = options?.drivers ?? {}; this.drivers = options?.drivers ?? {};
@@ -134,9 +138,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.plugins.set(config.name, config); this.plugins.set(config.name, config);
} }
this.runPlugins("onBoot"); this.runPlugins("onBoot");
this.modules = new ModuleManager(connection, {
// use db manager by default
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
this.modules = new Manager(connection, {
...(options?.manager ?? {}), ...(options?.manager ?? {}),
initial: _initialConfig, initial: _config,
onUpdated: this.onUpdated.bind(this), onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this), onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this), onServerInit: this.onServerInit.bind(this),
@@ -145,6 +153,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.modules.ctx().emgr.registerEvents(AppEvents); this.modules.ctx().emgr.registerEvents(AppEvents);
} }
get mode() {
return this.options?.mode ?? "db";
}
isReadOnly() {
return Boolean(this.mode === "code" || this.options?.readonly);
}
get emgr() { get emgr() {
return this.modules.ctx().emgr; return this.modules.ctx().emgr;
} }
@@ -175,7 +191,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return results as any; return results as any;
} }
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) { async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
// prevent multiple concurrent builds // prevent multiple concurrent builds
if (this._building) { if (this._building) {
while (this._building) { while (this._building) {
@@ -188,7 +204,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = true; this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true; if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch }); await this.modules.build();
const { guard } = this.modules.ctx(); const { guard } = this.modules.ctx();
@@ -215,10 +231,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = false; this._building = false;
} }
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.mutateConfigSafe(module);
}
get server() { get server() {
return this.modules.server; return this.modules.server;
} }
@@ -232,6 +244,10 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
} }
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
if (!this.isBuilt()) {
throw new Error("App is not built yet, run build() first");
}
return this.server.fetch as any; return this.server.fetch as any;
} }
@@ -290,13 +306,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
} }
getMcpClient() { getMcpClient() {
if (!this.mcp) { const config = this.modules.get("server").config.mcp;
if (!config.enabled) {
throw new Error("MCP is not enabled"); throw new Error("MCP is not enabled");
} }
const mcpPath = this.modules.get("server").config.mcp.path;
return new McpClient({ return new McpClient({
url: "http://localhost" + mcpPath, url: "http://localhost" + config.path,
fetch: this.server.request, fetch: this.server.request,
}); });
} }
@@ -377,5 +393,5 @@ export function createApp(config: CreateAppConfig = {}) {
throw new Error("Invalid connection"); throw new Error("Invalid connection");
} }
return new App(config.connection, config.initialConfig, config.options); return new App(config.connection, config.config, config.options);
} }

View File

@@ -1,6 +1,7 @@
import type { TestRunner } from "core/test"; import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index"; import type { BkndConfig, DefaultArgs } from "./index";
import type { App } from "App"; import type { App } from "App";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
export function adapterTestSuite< export function adapterTestSuite<
Config extends BkndConfig = BkndConfig, Config extends BkndConfig = BkndConfig,
@@ -13,24 +14,17 @@ export function adapterTestSuite<
label = "app", label = "app",
overrides = {}, overrides = {},
}: { }: {
makeApp: ( makeApp: (config: Config, args?: Args) => Promise<App>;
config: Config, makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
label?: string; label?: string;
overrides?: { overrides?: {
dbUrl?: string; dbUrl?: string;
}; };
}, },
) { ) {
const { test, expect, mock } = testRunner; const { test, expect, mock, beforeAll, afterAll } = testRunner;
const id = crypto.randomUUID(); beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
test(`creates ${label}`, async () => { test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any; const beforeBuild = mock(async () => null) as any;
@@ -39,7 +33,7 @@ export function adapterTestSuite<
const config = { const config = {
app: (env) => ({ app: (env) => ({
connection: { url: env.url }, connection: { url: env.url },
initialConfig: { config: {
server: { cors: { origin: env.origin } }, server: { cors: { origin: env.origin } },
}, },
}), }),
@@ -53,11 +47,10 @@ export function adapterTestSuite<
url: overrides.dbUrl ?? ":memory:", url: overrides.dbUrl ?? ":memory:",
origin: "localhost", origin: "localhost",
} as any, } as any,
{ force: false, id },
); );
expect(app).toBeDefined(); expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost"); expect(app.toJSON().server.cors.origin).toEqual("localhost");
expect(beforeBuild).toHaveBeenCalledTimes(1); expect(beforeBuild).toHaveBeenCalledTimes(2);
expect(onBuilt).toHaveBeenCalledTimes(1); expect(onBuilt).toHaveBeenCalledTimes(1);
}); });
@@ -68,8 +61,8 @@ export function adapterTestSuite<
return { res, data }; return { res, data };
}; };
test("responds with the same app id", async () => { /* test.skip("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { force: false, id }); const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher); const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
@@ -77,14 +70,14 @@ export function adapterTestSuite<
expect(data.server.cors.origin).toEqual("localhost"); expect(data.server.cors.origin).toEqual("localhost");
}); });
test("creates fresh & responds to api config", async () => { test.skip("creates fresh & responds to api config", async () => {
// set the same id, but force recreate // set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id, force: true }); const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher); const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*"); expect(data.server.cors.origin).toEqual("*");
}); }); */
} }
} }

View File

@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("astro adapter", () => { describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp, makeApp: astro.getApp,
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }), makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv; type AstroEnv = NodeJS.ProcessEnv;
type TAstro = { type TAstro = {
@@ -9,17 +9,12 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>( export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {}, config: AstroBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts: FrameworkOptions = {},
) { ) {
return await createFrameworkApp(config, args ?? import.meta.env, opts); return await createFrameworkApp(config, args ?? import.meta.env);
} }
export function serve<Env = AstroEnv>( export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (fnArgs: TAstro) => { return async (fnArgs: TAstro) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
}; };
} }

View File

@@ -1,7 +1,7 @@
import type { App } from "bknd"; import type { App } from "bknd";
import { handle } from "hono/aws-lambda"; import { handle } from "hono/aws-lambda";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
type AwsLambdaEnv = object; type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> = export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
@@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>( export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {}, { adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
): Promise<App> { ): Promise<App> {
let additional: Partial<RuntimeBkndConfig> = { let additional: Partial<RuntimeBkndConfig> = {
adminOptions, adminOptions,
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
...additional, ...additional,
}, },
args ?? process.env, args ?? process.env,
opts,
); );
} }
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>( export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {}, config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
return async (event) => { return async (event) => {
const app = await createApp(config, args, opts); const app = await createApp(config, args);
return await handle(app.server)(event); return await handle(app.server)(event);
}; };
} }

View File

@@ -11,8 +11,8 @@ describe("aws adapter", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: awsLambda.createApp, makeApp: awsLambda.createApp,
// @todo: add a request to lambda event translator? // @todo: add a request to lambda event translator?
makeHandler: (c, a, o) => async (request: Request) => { makeHandler: (c, a) => async (request: Request) => {
const app = await awsLambda.createApp(c, a, o); const app = await awsLambda.createApp(c, a);
return app.fetch(request); return app.fetch(request);
}, },
}); });

View File

@@ -1,7 +1,7 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "."; import { registerLocalMediaAdapter } from ".";
import { config, type App } from "bknd"; import { config, type App } from "bknd";
import type { ServeOptions } from "bun"; import type { ServeOptions } from "bun";
@@ -13,7 +13,6 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
export async function createApp<Env = BunEnv>( export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {}, { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter(); registerLocalMediaAdapter();
@@ -28,19 +27,17 @@ export async function createApp<Env = BunEnv>(
...config, ...config,
}, },
args ?? (process.env as Env), args ?? (process.env as Env),
opts,
); );
} }
export function createHandler<Env = BunEnv>( export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {}, config: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts); app = await createApp(config, args ?? (process.env as Env));
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -50,7 +47,7 @@ export function serve<Env = BunEnv>(
{ {
distPath, distPath,
connection, connection,
initialConfig, config: _config,
options, options,
port = config.server.default_port, port = config.server.default_port,
onBuilt, onBuilt,
@@ -60,7 +57,6 @@ export function serve<Env = BunEnv>(
...serveOptions ...serveOptions
}: BunBkndConfig<Env> = {}, }: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
Bun.serve({ Bun.serve({
...serveOptions, ...serveOptions,
@@ -68,7 +64,7 @@ export function serve<Env = BunEnv>(
fetch: createHandler( fetch: createHandler(
{ {
connection, connection,
initialConfig, config: _config,
options, options,
onBuilt, onBuilt,
buildConfig, buildConfig,
@@ -77,7 +73,6 @@ export function serve<Env = BunEnv>(
serveStatic, serveStatic,
}, },
args, args,
opts,
), ),
}); });

View File

@@ -1,8 +1,9 @@
import { connectionTestSuite } from "data/connection/connection-test-suite"; import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection"; import { bunSqlite } from "./BunSqliteConnection";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test"; import { describe, test, mock, expect } from "bun:test";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
describe("BunSqliteConnection", () => { describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, { connectionTestSuite(bunTestRunner, {
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
}), }),
rawDialectDetails: [], rawDialectDetails: [],
}); });
test("onCreateConnection", async () => {
const called = mock(() => null);
const conn = bunSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(Database);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(Database);
expect(called).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -1,40 +1,53 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { genericSqlite, type GenericSqliteConnection } from "bknd"; import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { omitKeys } from "bknd/utils";
export type BunSqliteConnection = GenericSqliteConnection<Database>; export type BunSqliteConnection = GenericSqliteConnection<Database>;
export type BunSqliteConnectionConfig = { export type BunSqliteConnectionConfig = Omit<
database: Database; GenericSqliteConnectionConfig<Database>,
}; "name" | "supports"
> &
({ database?: Database; url?: never } | { url?: string; database?: never });
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) { export function bunSqlite(config?: BunSqliteConnectionConfig) {
let db: Database; let db: Database | undefined;
if (config) { if (config) {
if ("database" in config) { if ("database" in config && config.database) {
db = config.database; db = config.database;
} else { } else if (config.url) {
db = new Database(config.url); db = new Database(config.url);
} }
} else { }
if (!db) {
db = new Database(":memory:"); db = new Database(":memory:");
} }
return genericSqlite("bun-sqlite", db, (utils) => { return genericSqlite(
//const fn = cache ? "query" : "prepare"; "bun-sqlite",
const getStmt = (sql: string) => db.prepare(sql); db,
(utils) => {
const getStmt = (sql: string) => db.prepare(sql);
return { return {
db, db,
query: utils.buildQueryFn({ query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])), all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => { run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || [])); const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return { return {
insertId: utils.parseBigInt(lastInsertRowid), insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes), numAffectedRows: utils.parseBigInt(changes),
}; };
}, },
}), }),
close: () => db.close(), close: () => db.close(),
}; };
}); },
omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
);
} }

View File

@@ -1,4 +1,4 @@
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test"; import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test";
export const bunTestRunner = { export const bunTestRunner = {
describe, describe,
@@ -8,4 +8,5 @@ export const bunTestRunner = {
beforeEach, beforeEach,
afterEach, afterEach,
afterAll, afterAll,
beforeAll,
}; };

View File

@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog); /* beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog); */
describe("cf adapter", () => { describe("cf adapter", () => {
const DB_URL = ":memory:"; const DB_URL = ":memory:";
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
const staticConfig = await makeConfig( const staticConfig = await makeConfig(
{ {
connection: { url: DB_URL }, connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } }, config: { data: { basepath: DB_URL } },
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
); );
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined(); expect(staticConfig.connection).toBeDefined();
const dynamicConfig = await makeConfig( const dynamicConfig = await makeConfig(
{ {
app: (env) => ({ app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } }, config: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL }, connection: { url: env.DB_URL },
}), }),
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
); );
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined(); expect(dynamicConfig.connection).toBeDefined();
}); });
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, { adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a, o) => { makeApp: async (c, a) => {
return await createApp(c, { env: a } as any, o); return await createApp(c, { env: a } as any);
}, },
makeHandler: (c, a, o) => { makeHandler: (c, a) => {
console.log("args", a); console.log("args", a);
return async (request: any) => { return async (request: any) => {
const app = await createApp( const app = await createApp(
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
connection: { url: DB_URL }, connection: { url: DB_URL },
}, },
a as any, a as any,
o,
); );
return app.fetch(request); return app.fetch(request);
}; };

View File

@@ -5,7 +5,7 @@ import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import type { MaybePromise } from "bknd"; import type { MaybePromise } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
declare global { declare global {
@@ -34,12 +34,8 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
}; };
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>( export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>, config: CloudflareBkndConfig<Env> = {},
ctx: Partial<CloudflareContext<Env>> = {}, ctx: Partial<CloudflareContext<Env>> = {},
opts: RuntimeOptions = {
// by default, require the app to be rebuilt every time
force: true,
},
) { ) {
const appConfig = await makeConfig( const appConfig = await makeConfig(
{ {
@@ -53,7 +49,7 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
}, },
ctx, ctx,
); );
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts); return await createRuntimeApp<Env>(appConfig, ctx?.env);
} }
// compatiblity // compatiblity

View File

@@ -1,11 +1,12 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite"; import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare"; import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection"; import { doSqlite } from "./DoConnection";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const script = ` const script = `
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
@@ -40,6 +41,9 @@ export default {
} }
`; `;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("doSqlite", async () => { describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, { connectionTestSuite(viTestRunner, {
makeConnection: async () => { makeConnection: async () => {

View File

@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite"; import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
import { Miniflare } from "miniflare"; import { Miniflare } from "miniflare";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("cacheWorkersKV", async () => { describe("cacheWorkersKV", async () => {
beforeAll(() => { beforeAll(() => {

View File

@@ -16,7 +16,7 @@ export {
type GetBindingType, type GetBindingType,
type BindingMap, type BindingMap,
} from "./bindings"; } from "./bindings";
export { constants, type CloudflareContext } from "./config"; export { constants, makeConfig, type CloudflareContext } from "./config";
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter"; export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
export { registries } from "bknd"; export { registries } from "bknd";
export { devFsVitePlugin, devFsWrite } from "./vite"; export { devFsVitePlugin, devFsWrite } from "./vite";

View File

@@ -5,8 +5,9 @@ import {
type CloudflareBkndConfig, type CloudflareBkndConfig,
type CloudflareEnv, type CloudflareEnv,
} from "bknd/adapter/cloudflare"; } from "bknd/adapter/cloudflare";
import type { PlatformProxy } from "wrangler"; import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
import process from "node:process"; import process from "node:process";
import { $console } from "bknd/utils";
export type WithPlatformProxyOptions = { export type WithPlatformProxyOptions = {
/** /**
@@ -14,22 +15,49 @@ export type WithPlatformProxyOptions = {
* You can override/force this by setting this option. * You can override/force this by setting this option.
*/ */
useProxy?: boolean; useProxy?: boolean;
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>( export function withPlatformProxy<Env extends CloudflareEnv>(
config?: CloudflareBkndConfig<Env>, config: CloudflareBkndConfig<Env> = {},
opts?: WithPlatformProxyOptions, opts?: WithPlatformProxyOptions,
) { ) {
const use_proxy = const use_proxy =
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1"; typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
let proxy: PlatformProxy | undefined; let proxy: PlatformProxy | undefined;
$console.log("Using cloudflare platform proxy");
async function getEnv(env?: Env): Promise<Env> { async function getEnv(env?: Env): Promise<Env> {
if (use_proxy) { if (use_proxy) {
if (!proxy) { if (!proxy) {
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy); proxy = await getPlatformProxy(opts?.proxyOptions);
proxy = await getPlatformProxy(); process.on("exit", () => {
setTimeout(proxy?.dispose, 1000); proxy?.dispose();
});
} }
return proxy.env as unknown as Env; return proxy.env as unknown as Env;
} }
@@ -50,16 +78,22 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
// @ts-ignore // @ts-ignore
app: async (_env) => { app: async (_env) => {
const env = await getEnv(_env); const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
if (config?.app === undefined && use_proxy) { if (config?.app === undefined && use_proxy && binding) {
const binding = getBinding(env, "D1Database");
return { return {
connection: d1Sqlite({ connection: d1Sqlite({
binding: binding.value, binding: binding.value,
}), }),
}; };
} else if (typeof config?.app === "function") { } else if (typeof config?.app === "function") {
return config?.app(env); const appConfig = await config?.app(env);
if (binding) {
appConfig.connection = d1Sqlite({
binding: binding.value,
}) as any;
}
return appConfig;
} }
return config?.app || {}; return config?.app || {};
}, },

View File

@@ -3,8 +3,12 @@ import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter"; import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import path from "node:path"; import path from "node:path";
import { describe, afterAll, test, expect } from "vitest"; import { describe, afterAll, test, expect, beforeAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
let mf: Miniflare | undefined; let mf: Miniflare | undefined;
describe("StorageR2Adapter", async () => { describe("StorageR2Adapter", async () => {
@@ -24,7 +28,8 @@ describe("StorageR2Adapter", async () => {
const buffer = readFileSync(path.join(basePath, "image.png")); const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" }); const file = new File([buffer], "image.png", { type: "image/png" });
await adapterTestSuite(viTestRunner, adapter, file); // miniflare doesn't support range requests
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
}); });
afterAll(async () => { afterAll(async () => {

View File

@@ -24,45 +24,157 @@ export function devFsVitePlugin({
projectRoot = config.root; projectRoot = config.root;
}, },
configureServer(server) { configureServer(server) {
if (!isDev) return; if (!isDev) {
verbose && console.debug("[dev-fs-plugin] Not in dev mode, skipping");
return;
}
// Track active chunked requests
const activeRequests = new Map<
string,
{
totalChunks: number;
filename: string;
chunks: string[];
receivedChunks: number;
}
>();
// Intercept stdout to watch for our write requests // Intercept stdout to watch for our write requests
const originalStdoutWrite = process.stdout.write; const originalStdoutWrite = process.stdout.write;
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) { process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
const output = chunk.toString(); const output = chunk.toString();
// Check if this output contains our special write request // Skip our own debug output
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) { if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
try { // @ts-ignore
// Extract the JSON from the log line // biome-ignore lint/style/noArguments: <explanation>
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/); return originalStdoutWrite.apply(process.stdout, arguments);
if (match) { }
const writeRequest = JSON.parse(match[1]);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
if (verbose) {
console.debug("[dev-fs-plugin] Intercepted write request via stdout");
}
// Process the write request immediately // Track if we process any protocol messages (to suppress output)
(async () => { let processedProtocolMessage = false;
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
await nodeWriteFile(fullPath, writeRequest.data);
if (verbose) {
console.debug("[dev-fs-plugin] File written successfully!");
}
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Don't output the raw write request to console // Process all start markers in this output
return true; if (output.includes("{{DEV_FS_START}}")) {
const startMatches = [
...output.matchAll(/{{DEV_FS_START}} ([a-z0-9]+) (\d+) (.+)/g),
];
for (const startMatch of startMatches) {
const requestId = startMatch[1];
const totalChunks = Number.parseInt(startMatch[2]);
const filename = startMatch[3];
activeRequests.set(requestId, {
totalChunks,
filename,
chunks: new Array(totalChunks),
receivedChunks: 0,
});
verbose &&
console.debug(
`[dev-fs-plugin] Started request ${requestId} for ${filename} (${totalChunks} chunks)`,
);
}
processedProtocolMessage = true;
}
// Process all chunk data in this output
if (output.includes("{{DEV_FS_CHUNK}}")) {
const chunkMatches = [
...output.matchAll(/{{DEV_FS_CHUNK}} ([a-z0-9]+) (\d+) ([A-Za-z0-9+/=]+)/g),
];
for (const chunkMatch of chunkMatches) {
const requestId = chunkMatch[1];
const chunkIndex = Number.parseInt(chunkMatch[2]);
const chunkData = chunkMatch[3];
const request = activeRequests.get(requestId);
if (request) {
request.chunks[chunkIndex] = chunkData;
request.receivedChunks++;
verbose &&
console.debug(
`[dev-fs-plugin] Received chunk ${chunkIndex}/${request.totalChunks - 1} for ${request.filename} (length: ${chunkData.length})`,
);
// Validate base64 chunk
if (chunkData.length < 1000 && chunkIndex < request.totalChunks - 1) {
verbose &&
console.warn(
`[dev-fs-plugin] WARNING: Chunk ${chunkIndex} seems truncated (length: ${chunkData.length})`,
);
} }
} }
} catch (error) {
// Not a valid write request, continue with normal output
} }
processedProtocolMessage = true;
}
// Process all end markers in this output
if (output.includes("{{DEV_FS_END}}")) {
const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)];
for (const endMatch of endMatches) {
const requestId = endMatch[1];
const request = activeRequests.get(requestId);
if (request && request.receivedChunks === request.totalChunks) {
try {
// Reconstruct the base64 string
const fullBase64 = request.chunks.join("");
verbose &&
console.debug(
`[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`,
);
// Decode and parse
const decodedJson = atob(fullBase64);
const writeRequest = JSON.parse(decodedJson);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
verbose &&
console.debug(
`[dev-fs-plugin] Processing write request for ${writeRequest.filename}`,
);
// Process the write request
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
verbose &&
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
await nodeWriteFile(fullPath, writeRequest.data);
verbose &&
console.debug("[dev-fs-plugin] File written successfully!");
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Clean up
activeRequests.delete(requestId);
return true;
}
} catch (error) {
console.error(
"[dev-fs-plugin] Error processing chunked request:",
String(error),
);
activeRequests.delete(requestId);
}
} else if (request) {
verbose &&
console.debug(
`[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`,
);
}
}
processedProtocolMessage = true;
}
// If we processed any protocol messages, suppress output
if (processedProtocolMessage) {
return callback ? callback() : true;
} }
// @ts-ignore // @ts-ignore
@@ -78,7 +190,10 @@ export function devFsVitePlugin({
// @ts-ignore // @ts-ignore
transform(code, id, options) { transform(code, id, options) {
// Only transform in SSR mode during development // Only transform in SSR mode during development
if (!isDev || !options?.ssr) return; //if (!isDev || !options?.ssr) return;
if (!isDev) {
return;
}
// Check if this is the bknd config file // Check if this is the bknd config file
if (id.includes(configFile)) { if (id.includes(configFile)) {
@@ -92,7 +207,7 @@ export function devFsVitePlugin({
if (typeof globalThis !== 'undefined') { if (typeof globalThis !== 'undefined') {
globalThis.__devFsPolyfill = { globalThis.__devFsPolyfill = {
writeFile: async (filename, data) => { writeFile: async (filename, data) => {
${verbose ? "console.debug('dev-fs polyfill: Intercepting write request for', filename);" : ""} ${verbose ? "console.debug('[dev-fs-polyfill] Intercepting write request for', filename);" : ""}
// Use console logging as a communication channel // Use console logging as a communication channel
// The main process will watch for this specific log pattern // The main process will watch for this specific log pattern
@@ -103,16 +218,38 @@ if (typeof globalThis !== 'undefined') {
timestamp: Date.now() timestamp: Date.now()
}; };
// Output as a specially formatted console message // Output as a specially formatted console message with end delimiter
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest)); // Base64 encode the JSON to avoid any control character issues
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""} const jsonString = JSON.stringify(writeRequest);
const encodedJson = btoa(jsonString);
// Split into reasonable chunks that balance performance vs reliability
const chunkSize = 2000; // 2KB chunks - safe for most environments
const chunks = [];
for (let i = 0; i < encodedJson.length; i += chunkSize) {
chunks.push(encodedJson.slice(i, i + chunkSize));
}
const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
// Send start marker (use stdout.write to avoid console display)
process.stdout.write('{{DEV_FS_START}} ' + requestId + ' ' + chunks.length + ' ' + filename + '\\n');
// Send each chunk
chunks.forEach((chunk, index) => {
process.stdout.write('{{DEV_FS_CHUNK}} ' + requestId + ' ' + index + ' ' + chunk + '\\n');
});
// Send end marker
process.stdout.write('{{DEV_FS_END}} ' + requestId + '\\n');
return Promise.resolve(); return Promise.resolve();
} }
}; };
} }`;
`;
return polyfill + code; return polyfill + code;
} else {
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
} }
}, },
} satisfies Plugin; } satisfies Plugin;

View File

@@ -13,21 +13,14 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
export type BkndConfig<Args = any> = CreateAppConfig & { export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>); app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>);
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>; beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
}; };
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>; export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type CreateAdapterAppOptions = {
force?: boolean;
id?: string;
};
export type FrameworkOptions = CreateAdapterAppOptions;
export type RuntimeOptions = CreateAdapterAppOptions;
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & { export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string; distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
@@ -41,7 +34,7 @@ export type DefaultArgs = {
export async function makeConfig<Args = DefaultArgs>( export async function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>, config: BkndConfig<Args>,
args?: Args, args?: Args,
): Promise<CreateAppConfig> { ): Promise<Omit<BkndConfig<Args>, "app">> {
let additionalConfig: CreateAppConfig = {}; let additionalConfig: CreateAppConfig = {};
const { app, ...rest } = config; const { app, ...rest } = config;
if (app) { if (app) {
@@ -59,45 +52,34 @@ export async function makeConfig<Args = DefaultArgs>(
} }
// a map that contains all apps by id // a map that contains all apps by id
const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>( export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config, config: Config = {} as Config,
args?: Args, args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> { ): Promise<App> {
const id = opts?.id ?? "app"; await config.beforeBuild?.(undefined, $registries);
let app = apps.get(id);
if (!app || opts?.force) {
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
app = App.create(appConfig); const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
if (!opts?.force) { let connection: Connection | undefined;
apps.set(id, app); if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
} }
appConfig.connection = connection;
} }
return app; return App.create(appConfig);
} }
export async function createFrameworkApp<Args = DefaultArgs>( export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {}, config: FrameworkBkndConfig = {},
args?: Args, args?: Args,
opts?: FrameworkOptions,
): Promise<App> { ): Promise<App> {
const app = await createAdapterApp(config, args, opts); const app = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
if (config.onBuilt) { if (config.onBuilt) {
@@ -120,9 +102,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
export async function createRuntimeApp<Args = DefaultArgs>( export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {}, { serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args, args?: Args,
opts?: RuntimeOptions,
): Promise<App> { ): Promise<App> {
const app = await createAdapterApp(config, args, opts); const app = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
app.emgr.onEvent( app.emgr.onEvent(

View File

@@ -1,4 +1,4 @@
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter"; import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
import { isNode } from "bknd/utils"; import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
@@ -10,9 +10,8 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>( export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>, config: NextjsBkndConfig<Env>,
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return await createFrameworkApp(config, args ?? (process.env as Env), opts); return await createFrameworkApp(config, args ?? (process.env as Env));
} }
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -41,10 +40,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>( export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {}, { cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return async (req: Request) => { return async (req: Request) => {
const app = await getApp(config, args, opts); const app = await getApp(config, args);
const request = getCleanRequest(req, cleanRequest); const request = getCleanRequest(req, cleanRequest);
return app.fetch(request); return app.fetch(request);
}; };

View File

@@ -1,19 +1,29 @@
import { genericSqlite } from "bknd"; import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import { omitKeys } from "bknd/utils";
export type NodeSqliteConnectionConfig = { export type NodeSqliteConnection = GenericSqliteConnection<DatabaseSync>;
database: DatabaseSync; export type NodeSqliteConnectionConfig = Omit<
}; GenericSqliteConnectionConfig<DatabaseSync>,
"name" | "supports"
> &
({ database?: DatabaseSync; url?: never } | { url?: string; database?: never });
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) { export function nodeSqlite(config?: NodeSqliteConnectionConfig) {
let db: DatabaseSync; let db: DatabaseSync | undefined;
if (config) { if (config) {
if ("database" in config) { if ("database" in config && config.database) {
db = config.database; db = config.database;
} else { } else if (config.url) {
db = new DatabaseSync(config.url); db = new DatabaseSync(config.url);
} }
} else { }
if (!db) {
db = new DatabaseSync(":memory:"); db = new DatabaseSync(":memory:");
} }
@@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
"node-sqlite", "node-sqlite",
db, db,
(utils) => { (utils) => {
const getStmt = (sql: string) => { const getStmt = (sql: string) => db.prepare(sql);
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return { return {
db, db,
@@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
}; };
}, },
{ {
...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
supports: { supports: {
batching: false, batching: false,
}, },

View File

@@ -1,8 +1,13 @@
import { nodeSqlite } from "./NodeSqliteConnection"; import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite"; import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe } from "vitest"; import { describe, beforeAll, afterAll, test, expect, vi } from "vitest";
import { viTestRunner } from "../vitest"; import { viTestRunner } from "../vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("NodeSqliteConnection", () => { describe("NodeSqliteConnection", () => {
connectionTestSuite(viTestRunner, { connectionTestSuite(viTestRunner, {
@@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => {
}), }),
rawDialectDetails: [], rawDialectDetails: [],
}); });
test("onCreateConnection", async () => {
const called = vi.fn(() => null);
const conn = nodeSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(DatabaseSync);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(DatabaseSync);
expect(called).toHaveBeenCalledOnce();
});
}); });

View File

@@ -2,7 +2,7 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server"; import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/storage"; import { registerLocalMediaAdapter } from "adapter/node/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { config as $config, type App } from "bknd"; import { config as $config, type App } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
@@ -18,7 +18,6 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>( export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {}, { distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
const root = path.relative( const root = path.relative(
process.cwd(), process.cwd(),
@@ -36,19 +35,17 @@ export async function createApp<Env = NodeEnv>(
}, },
// @ts-ignore // @ts-ignore
args ?? { env: process.env }, args ?? { env: process.env },
opts,
); );
} }
export function createHandler<Env = NodeEnv>( export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {}, config: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts); app = await createApp(config, args ?? (process.env as Env));
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -57,13 +54,12 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>( export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {}, { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
honoServe( honoServe(
{ {
port, port,
hostname, hostname,
fetch: createHandler(config, args, opts), fetch: createHandler(config, args),
}, },
(connInfo) => { (connInfo) => {
$console.log(`Server is running on http://localhost:${connInfo.port}`); $console.log(`Server is running on http://localhost:${connInfo.port}`);

View File

@@ -2,10 +2,6 @@ import { describe, beforeAll, afterAll } from "vitest";
import * as node from "./node.adapter"; import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("node adapter", () => { describe("node adapter", () => {
adapterTestSuite(viTestRunner, { adapterTestSuite(viTestRunner, {

View File

@@ -80,18 +80,79 @@ 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> { async getObject(key: string, headers: Headers): Promise<Response> {
try { try {
const content = await readFile(`${this.config.path}/${key}`); const filePath = `${this.config.path}/${key}`;
const stats = await stat(filePath);
const fileSize = stats.size;
const mimeType = guessMimeType(key); const mimeType = guessMimeType(key);
return new Response(content, { const responseHeaders = new Headers({
status: 200, "Accept-Ranges": "bytes",
headers: { "Content-Type": mimeType || "application/octet-stream",
"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) { } catch (error) {
// Handle file reading errors // Handle file reading errors
return new Response("", { status: 404 }); return new Response("", { status: 404 });

View File

@@ -1,9 +1,13 @@
import { describe } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { StorageLocalAdapter } from "adapter/node"; import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("StorageLocalAdapter (node)", async () => { describe("StorageLocalAdapter (node)", async () => {
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets"); const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");

View File

@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict"; import nodeAssert from "node:assert/strict";
import { test, describe, beforeEach, afterEach } from "node:test"; import { test, describe, beforeEach, afterEach, after, before } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test"; import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls // Track mock function calls
@@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = {
}), }),
beforeEach: beforeEach, beforeEach: beforeEach,
afterEach: afterEach, afterEach: afterEach,
afterAll: () => {}, afterAll: after,
beforeAll: before,
}; };

View File

@@ -1,5 +1,5 @@
import type { TestFn, TestRunner, Test } from "core/test"; import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; import { describe, test, expect, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) { function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any); return test(label, fn as any);
@@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = {
beforeEach: beforeEach, beforeEach: beforeEach,
afterEach: afterEach, afterEach: afterEach,
afterAll: afterAll, afterAll: afterAll,
beforeAll: beforeAll,
}; };

View File

@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("react-router adapter", () => { describe("react-router adapter", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: rr.getApp, makeApp: rr.getApp,
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }), makeHandler: (c, a) => (request: Request) => rr.serve(c, a?.env)({ request }),
}); });
}); });

View File

@@ -1,5 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import type { FrameworkOptions } from "adapter";
type ReactRouterEnv = NodeJS.ProcessEnv; type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = { type ReactRouterFunctionArgs = {
@@ -10,17 +9,15 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>( export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>, config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return await createFrameworkApp(config, args ?? process.env, opts); return await createFrameworkApp(config, args ?? process.env);
} }
export function serve<Env = ReactRouterEnv>( export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {}, config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return async (fnArgs: ReactRouterFunctionArgs) => { return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
}; };
} }

View File

@@ -1,7 +1,7 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
async function createApp<ViteEnv>( async function createApp<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {}, config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv, env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> { ): Promise<App> {
registerLocalMediaAdapter(); registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
], ],
}, },
env, env,
opts,
); );
} }
export function serve<ViteEnv>( export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env, opts); const app = await createApp(config, env);
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
}, },
}; };

View File

@@ -278,7 +278,9 @@ export class Authenticator<
} }
return payload as any; return payload as any;
} catch (e) {} } catch (e) {
$console.debug("Authenticator jwt verify error", String(e));
}
return; return;
} }
@@ -396,8 +398,9 @@ export class Authenticator<
if (headers.has("Authorization")) { if (headers.has("Authorization")) {
const bearerHeader = String(headers.get("Authorization")); const bearerHeader = String(headers.get("Authorization"));
token = bearerHeader.replace("Bearer ", ""); token = bearerHeader.replace("Bearer ", "");
} else if (is_context) { } else {
token = await this.getAuthCookie(c as Context); const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
token = await this.getAuthCookie(context);
} }
if (token) { if (token) {

View File

@@ -4,6 +4,7 @@ import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import c from "picocolors"; import c from "picocolors";
import { withConfigOptions } from "cli/utils/options"; import { withConfigOptions } from "cli/utils/options";
import { $console } from "bknd/utils";
export const config: CliCommand = (program) => { export const config: CliCommand = (program) => {
withConfigOptions(program.command("config")) withConfigOptions(program.command("config"))
@@ -19,7 +20,14 @@ export const config: CliCommand = (program) => {
config = getDefaultConfig(); config = getDefaultConfig();
} else { } else {
const app = await makeAppFromEnv(options); const app = await makeAppFromEnv(options);
config = app.toJSON(options.secrets); const manager = app.modules;
if (options.secrets) {
$console.warn("Including secrets in output");
config = manager.toJSON(true);
} else {
config = manager.extractSecrets().configs;
}
} }
config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config); config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config);
@@ -31,5 +39,7 @@ export const config: CliCommand = (program) => {
} else { } else {
console.info(JSON.parse(config)); console.info(JSON.parse(config));
} }
process.exit(0);
}); });
}; };

View File

@@ -34,4 +34,5 @@ async function action(options: { out?: string; clean?: boolean }) {
// biome-ignore lint/suspicious/noConsoleLog: // biome-ignore lint/suspicious/noConsoleLog:
console.log(c.green(`Assets copied to: ${c.bold(out)}`)); console.log(c.green(`Assets copied to: ${c.bold(out)}`));
process.exit(0);
} }

View File

@@ -40,7 +40,9 @@ const subjects = {
async function action(subject: string) { async function action(subject: string) {
if (subject in subjects) { if (subject in subjects) {
await subjects[subject](); await subjects[subject]();
process.exit(0);
} else { } else {
console.error("Invalid subject: ", subject); console.error("Invalid subject: ", subject);
process.exit(1);
} }
} }

View File

@@ -8,3 +8,4 @@ export { copyAssets } from "./copy-assets";
export { types } from "./types"; export { types } from "./types";
export { mcp } from "./mcp/mcp"; export { mcp } from "./mcp/mcp";
export { sync } from "./sync"; export { sync } from "./sync";
export { secrets } from "./secrets";

View File

@@ -9,18 +9,28 @@ export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number]; export type Platform = (typeof PLATFORMS)[number];
export async function serveStatic(server: Platform): Promise<MiddlewareHandler> { export async function serveStatic(server: Platform): Promise<MiddlewareHandler> {
const onNotFound = (path: string) => {
$console.debug("Couldn't resolve static file at", path);
};
switch (server) { switch (server) {
case "node": { case "node": {
const m = await import("@hono/node-server/serve-static"); const m = await import("@hono/node-server/serve-static");
const root = getRelativeDistPath() + "/static";
$console.debug("Serving static files from", root);
return m.serveStatic({ return m.serveStatic({
// somehow different for node // somehow different for node
root: getRelativeDistPath() + "/static", root,
onNotFound,
}); });
} }
case "bun": { case "bun": {
const m = await import("hono/bun"); const m = await import("hono/bun");
const root = path.resolve(getRelativeDistPath(), "static");
$console.debug("Serving static files from", root);
return m.serveStatic({ return m.serveStatic({
root: path.resolve(getRelativeDistPath(), "static"), root,
onNotFound,
}); });
} }
} }
@@ -66,6 +76,9 @@ export async function getConfigPath(filePath?: string) {
const config_path = path.resolve(process.cwd(), filePath); const config_path = path.resolve(process.cwd(), filePath);
if (await fileExists(config_path)) { if (await fileExists(config_path)) {
return config_path; return config_path;
} else {
$console.error(`Config file could not be resolved: ${config_path}`);
process.exit(1);
} }
} }

View File

@@ -2,9 +2,8 @@ import type { Config } from "@libsql/client/node";
import { StorageLocalAdapter } from "adapter/node/storage"; import { StorageLocalAdapter } from "adapter/node/storage";
import type { CliBkndConfig, CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import { config, type App, type CreateAppConfig } from "bknd"; import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { registries } from "modules/registries";
import c from "picocolors"; import c from "picocolors";
import path from "node:path"; import path from "node:path";
import { import {
@@ -60,7 +59,7 @@ type MakeAppConfig = {
connection?: CreateAppConfig["connection"]; connection?: CreateAppConfig["connection"];
server?: { platform?: Platform }; server?: { platform?: Platform };
setAdminHtml?: boolean; setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => MaybePromise<void>;
}; };
async function makeApp(config: MakeAppConfig) { async function makeApp(config: MakeAppConfig) {

View File

@@ -8,7 +8,7 @@ export const schema: CliCommand = (program) => {
.option("--pretty", "pretty print") .option("--pretty", "pretty print")
.action((options) => { .action((options) => {
const schema = getDefaultSchema(); const schema = getDefaultSchema();
// biome-ignore lint/suspicious/noConsoleLog: console.info(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); process.exit(0);
}); });
}; };

View File

@@ -0,0 +1,59 @@
import type { CliCommand } from "../types";
import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
import { transformObject } from "bknd/utils";
import { Option } from "commander";
export const secrets: CliCommand = (program) => {
withConfigOptions(program.command("secrets"))
.description("get app secrets")
.option("--template", "template output without the actual secrets")
.addOption(
new Option("--format <format>", "format output").choices(["json", "env"]).default("json"),
)
.option("--out <file>", "output file")
.action(
async (
options: WithConfigOptions<{ template: string; format: "json" | "env"; out: string }>,
) => {
const app = await makeAppFromEnv(options);
const manager = app.modules;
let secrets = manager.extractSecrets().secrets;
if (options.template) {
secrets = transformObject(secrets, () => "");
}
console.info("");
if (options.out) {
if (options.format === "env") {
await writeFile(
options.out,
Object.entries(secrets)
.map(([key, value]) => `${key}=${value}`)
.join("\n"),
);
} else {
await writeFile(options.out, JSON.stringify(secrets, null, 2));
}
console.info(`Secrets written to ${c.cyan(options.out)}`);
} else {
if (options.format === "env") {
console.info(
c.cyan(
Object.entries(secrets)
.map(([key, value]) => `${key}=${value}`)
.join("\n"),
),
);
} else {
console.info(secrets);
}
}
console.info("");
process.exit(0);
},
);
};

View File

@@ -7,13 +7,15 @@ import { withConfigOptions } from "cli/utils/options";
export const sync: CliCommand = (program) => { export const sync: CliCommand = (program) => {
withConfigOptions(program.command("sync")) withConfigOptions(program.command("sync"))
.description("sync database") .description("sync database")
.option("--dump", "dump operations to console instead of executing them") .option("--force", "perform database syncing operations")
.option("--seed", "perform seeding operations")
.option("--drop", "include destructive DDL operations") .option("--drop", "include destructive DDL operations")
.option("--out <file>", "output file") .option("--out <file>", "output file")
.option("--sql", "use sql output") .option("--sql", "use sql output")
.action(async (options) => { .action(async (options) => {
const app = await makeAppFromEnv(options); const app = await makeAppFromEnv(options);
const schema = app.em.schema(); const schema = app.em.schema();
console.info(c.dim("Checking database state..."));
const stmts = await schema.sync({ drop: options.drop }); const stmts = await schema.sync({ drop: options.drop });
console.info(""); console.info("");
@@ -24,22 +26,41 @@ export const sync: CliCommand = (program) => {
// @todo: currently assuming parameters aren't used // @todo: currently assuming parameters aren't used
const sql = stmts.map((d) => d.sql).join(";\n") + ";"; const sql = stmts.map((d) => d.sql).join(";\n") + ";";
if (options.dump) { if (options.force) {
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(`${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) { if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2); const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output); await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`); console.info(`SQL written to ${c.cyan(options.out)}`);
} else { } else {
console.info(options.sql ? c.cyan(sql) : stmts); console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info(
c.yellow(
"\nNo statements have been executed. Use --force to perform database syncing operations",
),
);
} }
process.exit(0);
} }
process.exit(0);
await schema.sync({ force: true, drop: options.drop });
console.info(c.cyan(sql));
console.info(`${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`);
}); });
}; };

View File

@@ -35,4 +35,6 @@ async function action({
await writeFile(outfile, et.toString()); await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`); console.info(`\nTypes written to ${c.cyan(outfile)}`);
} }
process.exit(0);
} }

View File

@@ -78,9 +78,11 @@ async function create(app: App, options: any) {
password: await strategy.hash(password as string), password: await strategy.hash(password as string),
}); });
$log.success(`Created user: ${c.cyan(created.email)}`); $log.success(`Created user: ${c.cyan(created.email)}`);
process.exit(0);
} catch (e) { } catch (e) {
$log.error("Error creating user"); $log.error("Error creating user");
$console.error(e); $console.error(e);
process.exit(1);
} }
} }
@@ -121,8 +123,10 @@ async function update(app: App, options: any) {
if (await app.module.auth.changePassword(user.id, password)) { if (await app.module.auth.changePassword(user.id, password)) {
$log.success(`Updated user: ${c.cyan(user.email)}`); $log.success(`Updated user: ${c.cyan(user.email)}`);
process.exit(0);
} else { } else {
$log.error("Error updating user"); $log.error("Error updating user");
process.exit(1);
} }
} }
@@ -158,4 +162,5 @@ async function token(app: App, options: any) {
console.log( console.log(
`\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`, `\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`,
); );
process.exit(0);
} }

View File

@@ -7,7 +7,7 @@ import { getVersion } from "./utils/sys";
import { capture, flush, init } from "cli/utils/telemetry"; import { capture, flush, init } from "cli/utils/telemetry";
const program = new Command(); const program = new Command();
export async function main() { async function main() {
await init(); await init();
capture("start"); capture("start");

View File

@@ -11,10 +11,12 @@ export interface AppEntity<IdType = number | string> {
export interface DB { export interface DB {
// make sure to make unknown as "any" // make sure to make unknown as "any"
[key: string]: { /* [key: string]: {
id: PrimaryFieldType; id: PrimaryFieldType;
[key: string]: any; [key: string]: any;
}; }; */
// @todo: that's not good, but required for admin options
[key: string]: any;
} }
export const config = { export const config = {

View File

@@ -31,6 +31,7 @@ export type TestRunner = {
beforeEach: (fn: () => MaybePromise<void>) => void; beforeEach: (fn: () => MaybePromise<void>) => void;
afterEach: (fn: () => MaybePromise<void>) => void; afterEach: (fn: () => MaybePromise<void>) => void;
afterAll: (fn: () => MaybePromise<void>) => void; afterAll: (fn: () => MaybePromise<void>) => void;
beforeAll: (fn: () => MaybePromise<void>) => void;
}; };
export async function retry<T>( export async function retry<T>(

View File

@@ -4,3 +4,5 @@ export interface Serializable<Class, Json extends object = object> {
} }
export type MaybePromise<T> = T | Promise<T>; export type MaybePromise<T> = T | Promise<T>;
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };

View File

@@ -396,6 +396,38 @@ export function getPath(
} }
} }
export function setPath(object: object, _path: string | (string | number)[], value: any) {
let path = _path;
// Optional string-path support.
// You can remove this `if` block if you don't need it.
if (typeof path === "string") {
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
path = path
.split(/[.\[\]]+/)
.filter((x) => x)
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
}
if (path.length === 0) {
throw new Error("The path must have at least one entry in it");
}
const [head, ...tail] = path as any;
if (tail.length === 0) {
object[head] = value;
return object;
}
if (!(head in object)) {
object[head] = typeof tail[0] === "number" ? [] : {};
}
setPath(object[head], tail, value);
return object;
}
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string { export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
const nl = indent ? "\n" : ""; const nl = indent ? "\n" : "";
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : ""); const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");

View File

@@ -12,6 +12,7 @@ export {
getMcpServer, getMcpServer,
stdioTransport, stdioTransport,
McpClient, McpClient,
logLevels as mcpLogLevels,
type McpClientConfig, type McpClientConfig,
type ToolAnnotation, type ToolAnnotation,
type ToolHandlerCtx, type ToolHandlerCtx,
@@ -21,8 +22,35 @@ export { secret, SecretSchema } from "./secret";
export { s }; export { s };
export const stripMark = <O extends object>(o: O): O => o; const symbol = Symbol("bknd-validation-mark");
export const mark = <O extends object>(o: O): O => o;
export function stripMark<O = any>(obj: O) {
const newObj = structuredClone(obj);
mark(newObj, false);
return newObj as O;
}
export function mark(obj: any, validated = true) {
try {
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
if (validated) {
obj[symbol] = true;
} else {
delete obj[symbol];
}
for (const key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
mark(obj[key], validated);
}
}
}
} catch (e) {}
}
export function isMarked(obj: any) {
if (typeof obj !== "object" || obj === null) return false;
return obj[symbol] === true;
}
export const stringIdentifier = s.string({ export const stringIdentifier = s.string({
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$", pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
@@ -38,7 +66,8 @@ export class InvalidSchemaError extends Error {
) { ) {
super( super(
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` + `Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
`Error: ${JSON.stringify(errors[0], null, 2)}`, `Error: ${JSON.stringify(errors[0], null, 2)}\n\n` +
`Schema: ${JSON.stringify(schema.toJSON(), null, 2)}`,
); );
} }
@@ -73,6 +102,10 @@ export function parse<S extends s.Schema, Options extends ParseOptions = ParseOp
v: unknown, v: unknown,
opts?: Options, opts?: Options,
): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> { ): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> {
if (!opts?.forceParse && !opts?.coerce && isMarked(v)) {
return v as any;
}
const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema; const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema;
let value = let value =
opts?.coerce !== false opts?.coerce !== false

View File

@@ -6,6 +6,8 @@ const _oldConsoles = {
warn: console.warn, warn: console.warn,
error: console.error, error: console.error,
}; };
let _oldStderr: any;
let _oldStdout: any;
export async function withDisabledConsole<R>( export async function withDisabledConsole<R>(
fn: () => Promise<R>, fn: () => Promise<R>,
@@ -36,10 +38,17 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
severities.forEach((severity) => { severities.forEach((severity) => {
console[severity] = () => null; console[severity] = () => null;
}); });
// Disable stderr
_oldStderr = process.stderr.write;
_oldStdout = process.stdout.write;
process.stderr.write = () => true;
process.stdout.write = () => true;
$console?.setLevel("critical"); $console?.setLevel("critical");
} }
export function enableConsoleLog() { export function enableConsoleLog() {
process.stderr.write = _oldStderr;
process.stdout.write = _oldStdout;
Object.entries(_oldConsoles).forEach(([severity, fn]) => { Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn; console[severity as ConsoleSeverity] = fn;
}); });

View File

@@ -42,6 +42,9 @@ export class DataApi extends ModuleApi<DataApiOptions> {
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = RepositoryResultJSON<Data>; type T = RepositoryResultJSON<Data>;
// @todo: if none found, still returns meta...
return this.readMany(entity, { return this.readMany(entity, {
...query, ...query,
limit: 1, limit: 1,

View File

@@ -60,6 +60,7 @@ export class DataController extends Controller {
"/sync", "/sync",
permission(DataPermissions.databaseSync), permission(DataPermissions.databaseSync),
mcpTool("data_sync", { mcpTool("data_sync", {
// @todo: should be removed if readonly
annotations: { annotations: {
destructiveHint: true, destructiveHint: true,
}, },

View File

@@ -230,3 +230,15 @@ export function customIntrospector<T extends Constructor<Dialect>>(
}, },
}; };
} }
export class DummyConnection extends Connection {
override name = "dummy";
constructor() {
super(undefined as any);
}
override getFieldSchema(): SchemaResponse {
throw new Error("Method not implemented.");
}
}

View File

@@ -4,6 +4,7 @@ import { getPath } from "bknd/utils";
import * as proto from "data/prototype"; import * as proto from "data/prototype";
import { createApp } from "App"; import { createApp } from "App";
import type { MaybePromise } from "core/types"; import type { MaybePromise } from "core/types";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
// @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc. // @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc.
// @todo: add toDriver/fromDriver tests on all types and fields // @todo: add toDriver/fromDriver tests on all types and fields
@@ -21,7 +22,9 @@ export function connectionTestSuite(
rawDialectDetails: string[]; rawDialectDetails: string[];
}, },
) { ) {
const { test, expect, describe, beforeEach, afterEach, afterAll } = testRunner; const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("base", () => { describe("base", () => {
let ctx: Awaited<ReturnType<typeof makeConnection>>; let ctx: Awaited<ReturnType<typeof makeConnection>>;
@@ -247,7 +250,7 @@ export function connectionTestSuite(
const app = createApp({ const app = createApp({
connection: ctx.connection, connection: ctx.connection,
initialConfig: { config: {
data: schema.toJSON(), data: schema.toJSON(),
}, },
}); });
@@ -333,7 +336,7 @@ export function connectionTestSuite(
const app = createApp({ const app = createApp({
connection: ctx.connection, connection: ctx.connection,
initialConfig: { config: {
data: schema.toJSON(), data: schema.toJSON(),
}, },
}); });

View File

@@ -1,7 +1,6 @@
import type { KyselyPlugin, QueryResult } from "kysely"; import type { KyselyPlugin, QueryResult } from "kysely";
import { import {
type IGenericSqlite, type IGenericSqlite,
type OnCreateConnection,
type Promisable, type Promisable,
parseBigInt, parseBigInt,
buildQueryFn, buildQueryFn,
@@ -9,6 +8,7 @@ import {
} from "kysely-generic-sqlite"; } from "kysely-generic-sqlite";
import { SqliteConnection } from "./SqliteConnection"; import { SqliteConnection } from "./SqliteConnection";
import type { ConnQuery, ConnQueryResults, Features } from "../Connection"; import type { ConnQuery, ConnQueryResults, Features } from "../Connection";
import type { MaybePromise } from "bknd";
export type { IGenericSqlite }; export type { IGenericSqlite };
export type TStatement = { sql: string; parameters?: any[] | readonly any[] }; export type TStatement = { sql: string; parameters?: any[] | readonly any[] };
@@ -16,11 +16,11 @@ export interface IGenericCustomSqlite<DB = unknown> extends IGenericSqlite<DB> {
batch?: (stmts: TStatement[]) => Promisable<QueryResult<any>[]>; batch?: (stmts: TStatement[]) => Promisable<QueryResult<any>[]>;
} }
export type GenericSqliteConnectionConfig = { export type GenericSqliteConnectionConfig<Database = unknown> = {
name?: string; name?: string;
additionalPlugins?: KyselyPlugin[]; additionalPlugins?: KyselyPlugin[];
excludeTables?: string[]; excludeTables?: string[];
onCreateConnection?: OnCreateConnection; onCreateConnection?: (db: Database) => MaybePromise<void>;
supports?: Partial<Features>; supports?: Partial<Features>;
}; };
@@ -35,7 +35,12 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
) { ) {
super({ super({
dialect: GenericSqliteDialect, dialect: GenericSqliteDialect,
dialectArgs: [executor, config?.onCreateConnection], dialectArgs: [
executor,
config?.onCreateConnection && typeof config.onCreateConnection === "function"
? (c: any) => config.onCreateConnection?.(c.db.db as any)
: undefined,
],
additionalPlugins: config?.additionalPlugins, additionalPlugins: config?.additionalPlugins,
excludeTables: config?.excludeTables, excludeTables: config?.excludeTables,
}); });
@@ -61,7 +66,6 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> { override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
const executor = await this.getExecutor(); const executor = await this.getExecutor();
if (!executor.batch) { if (!executor.batch) {
//$console.debug("Batching is not supported by this database");
return super.executeQueries(...qbs); return super.executeQueries(...qbs);
} }

View File

@@ -55,7 +55,8 @@ export class SqliteIntrospector extends BaseIntrospector {
)) FROM pragma_index_info(i.name) ii) )) FROM pragma_index_info(i.name) ii)
)) FROM pragma_index_list(m.name) i )) FROM pragma_index_list(m.name) i
LEFT JOIN sqlite_master im ON im.name = i.name LEFT JOIN sqlite_master im ON im.name = i.name
AND im.type = 'index' AND im.type = 'index'
WHERE i.name not like 'sqlite_%'
) AS indices ) AS indices
FROM sqlite_master m FROM sqlite_master m
WHERE m.type IN ('table', 'view') WHERE m.type IN ('table', 'view')

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "bun:test";
import { EntityTypescript } from "./EntityTypescript";
import * as proto from "../prototype";
import { DummyConnection } from "../connection/Connection";
describe("EntityTypescript", () => {
it("should generate correct typescript for system entities", () => {
const schema = proto.em(
{
test: proto.entity("test", {
name: proto.text(),
}),
users: proto.systemEntity("users", {
name: proto.text(),
}),
},
({ relation }, { test, users }) => {
relation(test).manyToOne(users);
},
);
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;");
});
});

View File

@@ -40,7 +40,7 @@ const systemEntities = {
export class EntityTypescript { export class EntityTypescript {
constructor( constructor(
protected em: EntityManager, protected em: EntityManager<any>,
protected _options: EntityTypescriptOptions = {}, protected _options: EntityTypescriptOptions = {},
) {} ) {}
@@ -50,7 +50,7 @@ export class EntityTypescript {
indentWidth: 2, indentWidth: 2,
indentChar: " ", indentChar: " ",
entityCommentMultiline: true, entityCommentMultiline: true,
fieldCommentMultiline: false, fieldCommentMultiline: true,
}; };
} }
@@ -82,7 +82,7 @@ export class EntityTypescript {
} }
typeName(name: string) { typeName(name: string) {
return autoFormatString(name); return autoFormatString(name).replace(/ /g, "");
} }
fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) { fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) {

View File

@@ -72,12 +72,12 @@ export class Result<T = unknown> {
return this.first().parameters; return this.first().parameters;
} }
get data() { get data(): T {
if (this.options.single) { if (this.options.single) {
return this.first().data?.[0]; return this.first().data?.[0];
} }
return this.first().data ?? []; return this.first().data ?? ([] as T);
} }
async execute(qb: Compilable | Compilable[]) { async execute(qb: Compilable | Compilable[]) {

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