Merge remote-tracking branch 'origin/release/0.15' into feat/plugin-improvements

# Conflicts:
#	app/package.json
#	app/src/App.ts
This commit is contained in:
dswbx
2025-06-13 17:24:54 +02:00
86 changed files with 1821 additions and 1782 deletions

View File

@@ -12,6 +12,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:

View File

@@ -1,4 +1,4 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, test } from "bun:test";
import { App } from "../src"; import { App } from "../src";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
import { Hono } from "hono"; import { Hono } from "hono";

View File

@@ -3,20 +3,35 @@ 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";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { omitKeys } from "core/utils";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("adapter", () => { describe("adapter", () => {
it("makes config", () => { it("makes config", () => {
expect(adapter.makeConfig({})).toEqual({}); expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({});
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({}); expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual(
{},
);
// merges everything returned from `app` with the config // merges everything returned from `app` with the config
expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({ expect(
env: { TEST: "test" }, omitKeys(
} as any); adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
initialConfig: { server: { cors: { origin: "test" } } },
}); });
});
/* it.only("...", async () => {
const app = await adapter.createAdapterApp();
}); */
it("reuses apps correctly", async () => { it("reuses apps correctly", async () => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();

View File

@@ -153,7 +153,7 @@ describe("DataApi", () => {
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] }); const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
const oneByRes = await oneBy; const oneByRes = await oneBy;
expect(oneByRes.data).toEqual({ title: "baz" } as any); expect(oneByRes.data).toEqual({ title: "baz" } as any);
expect(oneByRes.body.meta.count).toEqual(1); expect(oneByRes.body.meta.items).toEqual(1);
}); });
it("exists/count", async () => { it("exists/count", async () => {

View File

@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src"; import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "../../src/App"; import { App, createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype"; import * as proto from "../../src/data/prototype";
describe("App", () => { describe("App", () => {

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { registries } from "../../src";
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";

View File

@@ -7,13 +7,13 @@ import {
type EntityData, type EntityData,
EntityManager, EntityManager,
ManyToOneRelation, ManyToOneRelation,
type MutatorResponse,
type RepositoryResponse,
TextField, TextField,
} from "../../src/data"; } from "../../src/data";
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 { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"])); beforeAll(() => disableConsoleLog(["log", "warn"]));
@@ -21,52 +21,6 @@ afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {}); const dataConfig = parse(dataConfigSchema, {});
describe("[data] DataController", async () => { describe("[data] DataController", async () => {
test("repoResult", async () => {
const em = new EntityManager<any>([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.repoResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
meta: {
total: 0,
count: 0,
items: 0,
},
});
expect(res).toEqual({
meta: {
total: 0,
count: 0,
items: 0,
},
data: [],
});
});
test("mutatorResult", async () => {
const em = new EntityManager([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.mutatorResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
});
expect(res).toEqual({
data: [],
});
});
describe("getController", async () => { describe("getController", async () => {
const users = new Entity("users", [ const users = new Entity("users", [
new TextField("name", { required: true }), new TextField("name", { required: true }),
@@ -120,8 +74,7 @@ describe("[data] DataController", async () => {
method: "POST", method: "POST",
body: JSON.stringify(_user), body: JSON.stringify(_user),
}); });
//console.log("res", { _user }, res); const result = (await res.json()) as MutatorResultJSON;
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any; const { id, ...data } = result.data as any;
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -135,7 +88,7 @@ describe("[data] DataController", async () => {
method: "POST", method: "POST",
body: JSON.stringify(_post), body: JSON.stringify(_post),
}); });
const result = (await res.json()) as MutatorResponse; const result = (await res.json()) as MutatorResultJSON;
const { id, ...data } = result.data as any; const { id, ...data } = result.data as any;
expect(res.status).toBe(201); expect(res.status).toBe(201);
@@ -146,13 +99,13 @@ describe("[data] DataController", async () => {
test("/:entity (read many)", async () => { test("/:entity (read many)", async () => {
const res = await app.request("/entity/users"); const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3); //expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(3); expect(data.meta.items).toBe(3);
expect(data.data.length).toBe(3); expect(data.data.length).toBe(3);
expect(data.data[0].name).toBe("foo"); expect(data.data[0]?.name).toBe("foo");
}); });
test("/:entity/query (func query)", async () => { test("/:entity/query (func query)", async () => {
@@ -165,33 +118,32 @@ describe("[data] DataController", async () => {
where: { bio: { $isnull: 1 } }, where: { bio: { $isnull: 1 } },
}), }),
}); });
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("bar"); expect(data.data[0]?.name).toBe("bar");
}); });
test("/:entity (read many, paginated)", async () => { test("/:entity (read many, paginated)", async () => {
const res = await app.request("/entity/users?limit=1&offset=2"); const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3); //expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("baz"); expect(data.data[0]?.name).toBe("baz");
}); });
test("/:entity/:id (read one)", async () => { test("/:entity/:id (read one)", async () => {
const res = await app.request("/entity/users/3"); const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResponse<EntityData>; const data = (await res.json()) as RepositoryResultJSON<EntityData>;
console.log("data", data);
expect(data.meta.total).toBe(3); //expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] }); expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
}); });
@@ -201,7 +153,7 @@ describe("[data] DataController", async () => {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ name: "new name" }), body: JSON.stringify({ name: "new name" }),
}); });
const { data } = (await res.json()) as MutatorResponse; const { data } = (await res.json()) as MutatorResultJSON;
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" }); expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
@@ -209,27 +161,26 @@ describe("[data] DataController", async () => {
test("/:entity/:id/:reference (read references)", async () => { test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/entity/users/1/posts"); const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResultJSON;
console.log("data", data);
expect(data.meta.total).toBe(2); //expect(data.meta.total).toBe(2);
expect(data.meta.count).toBe(1); //expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1); expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1); expect(data.data.length).toBe(1);
expect(data.data[0].content).toBe("post 1"); expect(data.data[0]?.content).toBe("post 1");
}); });
test("/:entity/:id (delete one)", async () => { test("/:entity/:id (delete one)", async () => {
const res = await app.request("/entity/posts/2", { const res = await app.request("/entity/posts/2", {
method: "DELETE", method: "DELETE",
}); });
const { data } = (await res.json()) as RepositoryResponse<EntityData>; const { data } = (await res.json()) as RepositoryResultJSON<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] }); expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify // verify
const res2 = await app.request("/entity/posts"); const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse; const data2 = (await res2.json()) as RepositoryResultJSON;
expect(data2.meta.total).toBe(1); //expect(data2.meta.total).toBe(1);
}); });
}); });
}); });

View File

@@ -34,19 +34,12 @@ describe("some tests", async () => {
test("findId", async () => { test("findId", async () => {
const query = await em.repository(users).findId(1); const query = await em.repository(users).findId(1);
/*const { result, total, count, time } = query;
console.log("query", query.result, {
result,
total,
count,
time,
});*/
expect(query.sql).toBe( expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?', 'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
); );
expect(query.parameters).toEqual([1, 1]); expect(query.parameters).toEqual([1, 1]);
expect(query.result).toEqual([]); expect(query.data).toBeUndefined();
}); });
test("findMany", async () => { test("findMany", async () => {
@@ -56,7 +49,7 @@ describe("some tests", async () => {
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?', 'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?',
); );
expect(query.parameters).toEqual([10, 0]); expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]); expect(query.data).toEqual([]);
}); });
test("findMany with number", async () => { test("findMany with number", async () => {
@@ -66,7 +59,7 @@ describe("some tests", async () => {
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?', 'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?',
); );
expect(query.parameters).toEqual([10, 0]); expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]); expect(query.data).toEqual([]);
}); });
test("try adding an existing field name", async () => { test("try adding an existing field name", async () => {

View File

@@ -45,7 +45,7 @@ describe("Mutator simple", async () => {
}, },
}); });
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]); expect(query.data).toEqual([{ id: 1, label: "test", count: 1 }]);
}); });
test("update inserted row", async () => { test("update inserted row", async () => {
@@ -87,7 +87,7 @@ describe("Mutator simple", async () => {
expect(mutation.data).toEqual({ id, label: "new label", count: 100 }); expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
const query2 = await em.repository(items).findId(id); const query2 = await em.repository(items).findId(id);
expect(query2.result.length).toBe(0); expect(query2.data).toBeUndefined();
}); });
test("validation: insert incomplete row", async () => { test("validation: insert incomplete row", async () => {
@@ -177,13 +177,13 @@ describe("Mutator simple", async () => {
}); });
test("insertMany", async () => { test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count; const oldCount = (await em.repo(items).count()).data.count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }]; const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts); const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2); expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts); expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count; const newCount = (await em.repo(items).count()).data.count;
expect(newCount).toBe(oldCount + inserts.length); expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount }); const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });

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 { EventManager } from "../../../src/core/events"; import type { EventManager } from "../../../src/core/events";
import { import {
Entity, Entity,
@@ -12,11 +12,14 @@ import {
TextField, TextField,
} from "../../../src/data"; } from "../../../src/data";
import * as proto from "../../../src/data/prototype"; import * as proto from "../../../src/data/prototype";
import { getDummyConnection } from "../helper"; import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"]));
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => { describe("[data] Mutator (base)", async () => {
const entity = new Entity("items", [ const entity = new Entity("items", [
new TextField("label", { required: true }), new TextField("label", { required: true }),

View File

@@ -26,120 +26,6 @@ async function sleep(ms: number) {
} }
describe("[Repository]", async () => { describe("[Repository]", async () => {
test.skip("bulk", async () => {
//const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
const credentials = null as any; // @todo: determine what to do here
const connection = new LibsqlConnection(credentials);
const em = new EntityManager([], connection);
/*const emLibsql = new EntityManager([], {
url: connection.url.replace("https", "libsql"),
authToken: connection.authToken,
});*/
const table = "posts";
const client = connection.getClient();
if (!client) {
console.log("Cannot perform test without libsql connection");
return;
}
const conn = em.connection.kysely;
const selectQ = (e: E) => e.selectFrom(table).selectAll().limit(2);
const countQ = (e: E) => e.selectFrom(table).select(e.fn.count("*").as("count"));
async function executeTransaction(em: EntityManager<any>) {
return await em.connection.kysely.transaction().execute(async (e) => {
const res = await selectQ(e).execute();
const count = await countQ(e).execute();
return [res, count];
});
}
async function executeBatch(em: EntityManager<any>) {
const queries = [selectQ(conn), countQ(conn)];
return await em.connection.batchQuery(queries);
}
async function executeSingleKysely(em: EntityManager<any>) {
const res = await selectQ(conn).execute();
const count = await countQ(conn).execute();
return [res, count];
}
async function executeSingleClient(em: EntityManager<any>) {
const q1 = selectQ(conn).compile();
const res = await client.execute({
sql: q1.sql,
args: q1.parameters as any,
});
const q2 = countQ(conn).compile();
const count = await client.execute({
sql: q2.sql,
args: q2.parameters as any,
});
return [res, count];
}
const transaction = await executeTransaction(em);
const batch = await executeBatch(em);
expect(batch).toEqual(transaction as any);
const testperf = false;
if (testperf) {
const times = 5;
const exec = async (
name: string,
fn: (em: EntityManager<any>) => Promise<any>,
em: EntityManager<any>,
) => {
const res = await Perf.execute(() => fn(em), times);
await sleep(1000);
const info = {
name,
total: res.total.toFixed(2),
avg: (res.total / times).toFixed(2),
first: res.marks[0].time.toFixed(2),
last: res.marks[res.marks.length - 1].time.toFixed(2),
};
console.log(info.name, info, res.marks);
return info;
};
const data: any[] = [];
data.push(await exec("transaction.http", executeTransaction, em));
data.push(await exec("bulk.http", executeBatch, em));
data.push(await exec("singleKy.http", executeSingleKysely, em));
data.push(await exec("singleCl.http", executeSingleClient, em));
/*data.push(await exec("transaction.libsql", executeTransaction, emLibsql));
data.push(await exec("bulk.libsql", executeBatch, emLibsql));
data.push(await exec("singleKy.libsql", executeSingleKysely, emLibsql));
data.push(await exec("singleCl.libsql", executeSingleClient, emLibsql));*/
console.table(data);
/**
* ┌───┬────────────────────┬────────┬────────┬────────┬────────┐
* │ │ name │ total │ avg │ first │ last │
* ├───┼────────────────────┼────────┼────────┼────────┼────────┤
* │ 0 │ transaction.http │ 681.29 │ 136.26 │ 136.46 │ 396.09 │
* │ 1 │ bulk.http │ 164.82 │ 32.96 │ 32.95 │ 99.91 │
* │ 2 │ singleKy.http │ 330.01 │ 66.00 │ 65.86 │ 195.41 │
* │ 3 │ singleCl.http │ 326.17 │ 65.23 │ 61.32 │ 198.08 │
* │ 4 │ transaction.libsql │ 856.79 │ 171.36 │ 132.31 │ 595.24 │
* │ 5 │ bulk.libsql │ 180.63 │ 36.13 │ 35.39 │ 107.71 │
* │ 6 │ singleKy.libsql │ 347.11 │ 69.42 │ 65.00 │ 207.14 │
* │ 7 │ singleCl.libsql │ 328.60 │ 65.72 │ 62.19 │ 195.04 │
* └───┴────────────────────┴────────┴────────┴────────┴────────┘
*/
}
});
test("count & exists", async () => { test("count & exists", async () => {
const items = new Entity("items", [new TextField("label")]); const items = new Entity("items", [new TextField("label")]);
const em = new EntityManager([items], dummyConnection); const em = new EntityManager([items], dummyConnection);
@@ -160,25 +46,44 @@ describe("[Repository]", async () => {
// count all // count all
const res = await em.repository(items).count(); const res = await em.repository(items).count();
expect(res.sql).toBe('select count(*) as "count" from "items"'); expect(res.sql).toBe('select count(*) as "count" from "items"');
expect(res.data.count).toBe(3);
//
{
const res = await em.repository(items).findMany();
expect(res.count).toBe(3); expect(res.count).toBe(3);
}
{
const res = await em
.repository(items, {
includeCounts: true,
})
.findMany();
expect(res.count).toBe(3);
}
// count filtered // count filtered
const res2 = await em.repository(items).count({ label: { $in: ["a", "b"] } }); const res2 = await em
.repository(items, {
includeCounts: true,
})
.count({ label: { $in: ["a", "b"] } });
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)'); expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
expect(res2.parameters).toEqual(["a", "b"]); expect(res2.parameters).toEqual(["a", "b"]);
expect(res2.count).toBe(2); expect(res2.data.count).toBe(2);
// check exists // check exists
const res3 = await em.repository(items).exists({ label: "a" }); const res3 = await em.repository(items).exists({ label: "a" });
expect(res3.exists).toBe(true); expect(res3.data.exists).toBe(true);
const res4 = await em.repository(items).exists({ label: "d" }); const res4 = await em.repository(items).exists({ label: "d" });
expect(res4.exists).toBe(false); expect(res4.data.exists).toBe(false);
// for now, allow empty filter // for now, allow empty filter
const res5 = await em.repository(items).exists({}); const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true); expect(res5.data.exists).toBe(true);
}); });
test("option: silent", async () => { test("option: silent", async () => {
@@ -191,6 +96,9 @@ describe("[Repository]", async () => {
// should throw because table doesn't exist // should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/); expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result // should silently return empty result
em.repo("items", { silent: true })
.findMany({})
.then((r) => r.data);
expect( expect(
em em
.repo("items", { silent: true }) .repo("items", { silent: true })
@@ -209,16 +117,16 @@ describe("[Repository]", async () => {
expect( expect(
em em
.repo("items") .repo("items", { includeCounts: true })
.findMany({}) .findMany({})
.then((r) => [r.meta.count, r.meta.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([0, 0]); ).resolves.toEqual([0, 0]);
expect( expect(
em em
.repo("items", { includeCounts: false }) .repo("items", { includeCounts: false })
.findMany({}) .findMany({})
.then((r) => [r.meta.count, r.meta.total]), .then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]); ).resolves.toEqual([undefined, undefined]);
}); });
}); });

View File

@@ -38,14 +38,15 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" }; return { url: "http://127.0.0.1:8080" };
} }
type ConsoleSeverity = "log" | "warn" | "error"; type ConsoleSeverity = "debug" | "log" | "warn" | "error";
const _oldConsoles = { const _oldConsoles = {
debug: console.debug,
log: console.log, log: console.log,
warn: console.warn, warn: console.warn,
error: console.error, error: console.error,
}; };
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) { export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
severities.forEach((severity) => { severities.forEach((severity) => {
console[severity] = () => null; console[severity] = () => null;
}); });

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { createApp } from "../../src"; import { createApp } from "core/test/utils";
import { Api } from "../../src/Api"; import { Api } from "../../src/Api";
describe("integration config", () => { describe("integration config", () => {

View File

@@ -1,7 +1,8 @@
/// <reference types="@types/bun" /> /// <reference types="@types/bun" />
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { registries } from "../../src";
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";

View File

@@ -1,5 +1,5 @@
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "../../src"; 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 "../../src/data"; import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules"; import { AppAuth, type ModuleBuildContext } from "../../src/modules";

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { em, entity, text } from "../../src/data"; import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/modules"; import { AppMedia } from "../../src/modules";

View File

@@ -245,7 +245,11 @@ async function buildAdapters() {
// specific adatpers // specific adatpers
await tsup.build(baseConfig("react-router")); await tsup.build(baseConfig("react-router"));
await tsup.build(baseConfig("bun")); await tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
}),
);
await tsup.build(baseConfig("astro")); await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws")); await tsup.build(baseConfig("aws"));
await tsup.build( await tsup.build(
@@ -268,6 +272,29 @@ async function buildAdapters() {
...baseConfig("node"), ...baseConfig("node"),
platform: "node", platform: "node",
}); });
await tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
});
await tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
});
await tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
});
} }
await buildApi(); await buildApi();

View File

@@ -13,6 +13,9 @@
"bugs": { "bugs": {
"url": "https://github.com/bknd-io/bknd/issues" "url": "https://github.com/bknd-io/bknd/issues"
}, },
"engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"dev": "BKND_CLI_LOG_LEVEL=debug vite", "dev": "BKND_CLI_LOG_LEVEL=debug vite",
"build": "NODE_ENV=production bun run build.ts --minify --types", "build": "NODE_ENV=production bun run build.ts --minify --types",
@@ -31,11 +34,9 @@
"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",
"test:bun": "ALL_TESTS=1 bun test --bail", "test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')", "test:node": "vitest run",
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail", "test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest": "vitest run",
"test:vitest:watch": "vitest",
"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": "bun run e2e/adapters.ts",
@@ -50,7 +51,6 @@
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1", "@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1", "@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1", "@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "0.34.30", "@sinclair/typebox": "0.34.30",
@@ -61,11 +61,11 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8", "fast-xml-parser": "^5.0.8",
"hono": "^4.7.11",
"json-schema-form-react": "^0.0.2", "json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6", "kysely": "^0.27.6",
"hono": "^4.7.11",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
@@ -75,10 +75,13 @@
"devDependencies": { "devDependencies": {
"@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",
"@cloudflare/vitest-pool-workers": "^0.8.38",
"@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.3.3", "@hono/typebox-validator": "^0.3.3",
"@hono/vite-dev-server": "^0.19.1", "@hono/vite-dev-server": "^0.19.1",
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.9",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1", "@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1", "@mantine/notifications": "^7.17.1",
@@ -101,6 +104,8 @@
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.1.0", "jsonv-ts": "^0.1.0",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1",
"libsql-stateless-easy": "^1.8.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -124,8 +129,7 @@
"vite": "^6.3.5", "vite": "^6.3.5",
"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"
"@cloudflare/workers-types": "^4.20250606.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.14.3" "@hono/node-server": "^1.14.3"
@@ -182,6 +186,20 @@
"types": "./dist/types/media/index.d.ts", "types": "./dist/types/media/index.d.ts",
"import": "./dist/media/index.js", "import": "./dist/media/index.js",
"require": "./dist/media/index.js" "require": "./dist/media/index.js"
},
"./adapter/sqlite": {
"types": "./dist/types/adapter/sqlite/edge.d.ts",
"import": {
"workerd": "./dist/adapter/sqlite/edge.js",
"edge-light": "./dist/adapter/sqlite/edge.js",
"netlify": "./dist/adapter/sqlite/edge.js",
"vercel": "./dist/adapter/sqlite/edge.js",
"browser": "./dist/adapter/sqlite/edge.js",
"bun": "./dist/adapter/sqlite/bun.js",
"node": "./dist/adapter/sqlite/node.js",
"default": "./dist/adapter/sqlite/node.js"
},
"require": "./dist/adapter/sqlite/node.js"
}, },
"./plugins": { "./plugins": {
"types": "./dist/types/plugins/index.d.ts", "types": "./dist/types/plugins/index.d.ts",
@@ -244,6 +262,7 @@
"cli": ["./dist/types/cli/index.d.ts"], "cli": ["./dist/types/cli/index.d.ts"],
"media": ["./dist/types/media/index.d.ts"], "media": ["./dist/types/media/index.d.ts"],
"plugins": ["./dist/types/plugins/index.d.ts"], "plugins": ["./dist/types/plugins/index.d.ts"],
"sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"],
"adapter": ["./dist/types/adapter/index.d.ts"], "adapter": ["./dist/types/adapter/index.d.ts"],
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"], "adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"], "adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],

View File

@@ -1,7 +1,8 @@
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core"; import { $console } from "core";
import { Event } from "core/events"; import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection, type em as prototypeEm } from "data"; import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
ModuleManager, ModuleManager,
@@ -61,15 +62,8 @@ export type AppOptions = {
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">; manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none"; asyncEventsMode?: "sync" | "async" | "none";
}; };
export type CreateAppConfig = { export type CreateAppConfig<C extends Connection = Connection> = {
connection?: connection?: C | { url: string };
| Connection
| {
// @deprecated
type: "libsql";
config: LibSqlCredentials;
}
| LibSqlCredentials;
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
options?: AppOptions; options?: AppOptions;
}; };
@@ -77,7 +71,7 @@ export type CreateAppConfig = {
export type AppConfig = InitialModuleConfigs; export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions; export type LocalApiOptions = Request | ApiOptions;
export class App { export class App<C extends Connection = Connection> {
static readonly Events = AppEvents; static readonly Events = AppEvents;
modules: ModuleManager; modules: ModuleManager;
@@ -89,7 +83,7 @@ export class App {
private _building: boolean = false; private _building: boolean = false;
constructor( constructor(
private connection: Connection, public connection: C,
_initialConfig?: InitialModuleConfigs, _initialConfig?: InitialModuleConfigs,
private options?: AppOptions, private options?: AppOptions,
) { ) {
@@ -317,31 +311,9 @@ export class App {
} }
export function createApp(config: CreateAppConfig = {}) { export function createApp(config: CreateAppConfig = {}) {
let connection: Connection | undefined = undefined; if (!config.connection || !Connection.isConnection(config.connection)) {
try {
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
if ("type" in config.connection) {
$console.warn(
"Using deprecated connection type 'libsql', use the 'config' object directly.",
);
connection = new LibsqlConnection(config.connection.config);
} else {
connection = new LibsqlConnection(config.connection);
}
} else {
connection = new LibsqlConnection({ url: ":memory:" });
$console.warn("No connection provided, using in-memory database");
}
} catch (e) {
$console.error("Could not create connection", e);
}
if (!connection) {
throw new Error("Invalid connection"); throw new Error("Invalid connection");
} }
return new App(connection, config.initialConfig, config.options); return new App(config.connection, config.initialConfig, config.options);
} }

View File

@@ -2,10 +2,11 @@
import path from "node:path"; import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { registerLocalMediaAdapter } from ".";
import { config } from "bknd/core"; import { config } from "bknd/core";
import type { ServeOptions } from "bun"; import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import type { App } from "App";
type BunEnv = Bun.Env; type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">; export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
@@ -33,8 +34,11 @@ export function createHandler<Env = BunEnv>(
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions, opts?: RuntimeOptions,
) { ) {
let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts); if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
}
return app.fetch(req); return app.fetch(req);
}; };
} }
@@ -72,5 +76,5 @@ export function serve<Env = BunEnv>(
), ),
}); });
console.log(`Server is running on http://localhost:${port}`); console.info(`Server is running on http://localhost:${port}`);
} }

View File

@@ -0,0 +1,12 @@
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection";
import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test";
import { Database } from "bun:sqlite";
describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => bunSqlite({ database: new Database(":memory:") }),
rawDialectDetails: [],
});
});

View File

@@ -0,0 +1,48 @@
import { Database } from "bun:sqlite";
import {
buildQueryFn,
GenericSqliteConnection,
parseBigInt,
type IGenericSqlite,
} from "data/connection/sqlite/GenericSqliteConnection";
export type BunSqliteConnectionConfig = {
database: Database;
};
function bunSqliteExecutor(db: Database, cache: boolean): IGenericSqlite<Database> {
const fn = cache ? "query" : "prepare";
const getStmt = (sql: string) => db[fn](sql);
return {
db,
query: buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: parseBigInt(lastInsertRowid),
numAffectedRows: parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
}
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
let database: Database;
if (config) {
if ("database" in config) {
database = config.database;
} else {
database = new Database(config.url);
}
} else {
database = new Database(":memory:");
}
return new GenericSqliteConnection(database, () => bunSqliteExecutor(database, false), {
name: "bun-sqlite",
});
}

View File

@@ -1 +1,3 @@
export * from "./bun.adapter"; export * from "./bun.adapter";
export * from "../node/storage";
export * from "./connection/BunSqliteConnection";

View File

@@ -1,6 +1,7 @@
import { expect, test, mock } from "bun:test"; import { expect, test, mock, describe } from "bun:test";
export const bunTestRunner = { export const bunTestRunner = {
describe,
expect, expect,
test, test,
mock, mock,

View File

@@ -13,30 +13,32 @@ describe("cf adapter", () => {
const DB_URL = ":memory:"; const DB_URL = ":memory:";
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({ const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
request: request ?? (null as any), request: request ?? (null as any),
env: env ?? { DB_URL }, env: env ?? { url: DB_URL },
ctx: ctx ?? (null as any), ctx: ctx ?? (null as any),
}); });
it("makes config", async () => { it("makes config", async () => {
expect( const staticConfig = makeConfig(
makeConfig(
{ {
connection: { url: DB_URL }, connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } },
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
), );
).toEqual({ connection: { url: DB_URL } }); expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined();
expect( const dynamicConfig = makeConfig(
makeConfig(
{ {
app: (env) => ({ app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL }, connection: { url: env.DB_URL },
}), }),
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
), );
).toEqual({ connection: { url: DB_URL } }); expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined();
}); });
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, { adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {

View File

@@ -9,6 +9,7 @@ import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { Context, ExecutionContext } from "hono"; import type { Context, ExecutionContext } from "hono";
import { $console } from "core"; import { $console } from "core";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
import { sqlite } from "bknd/adapter/sqlite";
export const constants = { export const constants = {
exec_async_event_id: "cf_register_waituntil", exec_async_event_id: "cf_register_waituntil",
@@ -98,19 +99,35 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
const appConfig = makeAdapterConfig(config, args?.env); const appConfig = makeAdapterConfig(config, args?.env);
if (args?.env) { // if connection instance is given, don't do anything
const bindings = config.bindings?.(args?.env); // other than checking if D1 session is defined
if (D1Connection.isConnection(appConfig.connection)) {
if (config.d1?.session) {
// we cannot guarantee that db was opened with session
throw new Error(
"D1 session don't work when D1 is directly given as connection. Define it in bindings instead.",
);
}
// if connection is given, try to open with unified sqlite adapter
} else if (appConfig.connection) {
appConfig.connection = sqlite(appConfig.connection);
// if connection is not given, but env is set
// try to make D1 from bindings
} else if (args?.env) {
const bindings = config.bindings?.(args?.env);
const sessionHelper = d1SessionHelper(config); const sessionHelper = d1SessionHelper(config);
const sessionId = sessionHelper.get(args.request); const sessionId = sessionHelper.get(args.request);
let session: D1DatabaseSession | undefined; let session: D1DatabaseSession | undefined;
if (!appConfig.connection) {
let db: D1Database | undefined; let db: D1Database | undefined;
// if db is given in bindings, use it
if (bindings?.db) { if (bindings?.db) {
$console.log("Using database from bindings"); $console.log("Using database from bindings");
db = bindings.db; db = bindings.db;
} else if (Object.keys(args).length > 0) {
// scan for D1Database in args
} else {
const binding = getBinding(args.env, "D1Database"); const binding = getBinding(args.env, "D1Database");
if (binding) { if (binding) {
$console.log(`Using database from env "${binding.key}"`); $console.log(`Using database from env "${binding.key}"`);
@@ -118,19 +135,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
} }
} }
// if db is found, check if session is requested
if (db) { if (db) {
if (config.d1?.session) { if (config.d1?.session) {
session = db.withSession(sessionId ?? config.d1?.first); session = db.withSession(sessionId ?? config.d1?.first);
appConfig.connection = new D1Connection({ binding: session });
} else {
appConfig.connection = new D1Connection({ binding: db });
}
} else {
throw new Error("No database connection given");
}
}
if (config.d1?.session) { appConfig.connection = new D1Connection({ binding: session });
appConfig.options = { appConfig.options = {
...appConfig.options, ...appConfig.options,
manager: { manager: {
@@ -143,8 +153,15 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
}, },
}, },
}; };
} else {
appConfig.connection = new D1Connection({ binding: db });
} }
} }
}
if (!D1Connection.isConnection(appConfig.connection)) {
throw new Error("Couldn't find database connection");
}
return appConfig; return appConfig;
} }

View File

@@ -1,65 +1,42 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data"; import { SqliteConnection } from "bknd/data";
import type { QB } from "data/connection/Connection"; import type { ConnQuery, ConnQueryResults } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1"; import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = { export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
binding: DB; binding: DB;
}; };
class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV", "_cf_METADATA"],
});
}
}
export class D1Connection< export class D1Connection<
DB extends D1Database | D1DatabaseSession = D1Database, DB extends D1Database | D1DatabaseSession = D1Database,
> extends SqliteConnection { > extends SqliteConnection<DB> {
override name = "sqlite-d1";
protected override readonly supported = { protected override readonly supported = {
batching: true, batching: true,
softscans: false,
}; };
constructor(private config: D1ConnectionConfig<DB>) { constructor(private config: D1ConnectionConfig<DB>) {
const plugins = [new ParseJSONResultsPlugin()]; super({
excludeTables: ["_cf_KV", "_cf_METADATA"],
const kysely = new Kysely({ dialect: D1Dialect,
dialect: new CustomD1Dialect({ database: config.binding as D1Database }), dialectArgs: [{ database: config.binding as D1Database }],
plugins,
}); });
super(kysely, {}, plugins);
} }
get client(): DB { override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
return this.config.binding; const compiled = this.getCompiled(...qbs);
}
protected override async batch<Queries extends QB[]>(
queries: [...Queries],
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const db = this.config.binding; const db = this.config.binding;
const res = await db.batch( const res = await db.batch(
queries.map((q) => { compiled.map(({ sql, parameters }) => {
const { sql, parameters } = q.compile();
return db.prepare(sql).bind(...parameters); return db.prepare(sql).bind(...parameters);
}), }),
); );
// let it run through plugins return this.withTransformedRows(res, "results") as any;
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.results);
data.push(rows);
}
return data;
} }
} }

View File

@@ -64,7 +64,7 @@ export class DurableBkndApp extends DurableObject {
"type" in config.connection && "type" in config.connection &&
config.connection.type === "libsql" config.connection.type === "libsql"
) { ) {
config.connection.config.protocol = "wss"; //config.connection.config.protocol = "wss";
} }
this.app = await createRuntimeApp({ this.app = await createRuntimeApp({

View File

@@ -1,32 +0,0 @@
import { createWriteStream, readFileSync } from "node:fs";
import { test } from "node:test";
import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media";
import { nodeTestRunner } from "adapter/node/test";
import path from "node:path";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
test("StorageR2Adapter", async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"],
});
const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket;
const adapter = new StorageR2Adapter(bucket);
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
await adapterTestSuite(nodeTestRunner, adapter, file);
await mf.dispose();
});

View File

@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import path from "node:path";
import { describe, afterAll, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
let mf: Miniflare | undefined;
describe("StorageR2Adapter", async () => {
mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"],
});
const bucket = (await mf?.getR2Bucket("BUCKET")) as unknown as R2Bucket;
test("test", () => {
expect(bucket).toBeDefined();
});
const adapter = new StorageR2Adapter(bucket);
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
await adapterTestSuite(viTestRunner, adapter, file);
});
afterAll(async () => {
await mf?.dispose();
});

View File

@@ -0,0 +1,14 @@
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
miniflare: {
compatibilityDate: "2025-06-04",
},
},
},
include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
},
});

View File

@@ -1,7 +1,10 @@
import { App, type CreateAppConfig } from "bknd"; import { App, type CreateAppConfig } from "bknd";
import { config as $config } from "bknd/core"; import { config as $config, $console } from "bknd/core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
import { Connection } from "bknd/data";
export { Connection } from "bknd/data";
export type BkndConfig<Args = any> = CreateAppConfig & { export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => CreateAppConfig); app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
@@ -59,7 +62,20 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
const id = opts?.id ?? "app"; const id = opts?.id ?? "app";
let app = apps.get(id); let app = apps.get(id);
if (!app || opts?.force) { if (!app || opts?.force) {
app = App.create(makeConfig(config, args)); const appConfig = 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;
connection = sqlite(config.connection ?? { url: ":memory:" });
$console.info(`Using ${connection.name} connection`);
}
appConfig.connection = connection;
}
app = App.create(appConfig);
apps.set(id, app); apps.set(id, app);
} }
return app; return app;

View File

@@ -0,0 +1,57 @@
import {
buildQueryFn,
GenericSqliteConnection,
parseBigInt,
type IGenericSqlite,
} from "../../../data/connection/sqlite/GenericSqliteConnection";
import { DatabaseSync } from "node:sqlite";
export type NodeSqliteConnectionConfig = {
database: DatabaseSync;
};
function nodeSqliteExecutor(db: DatabaseSync): IGenericSqlite<DatabaseSync> {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return {
db,
query: buildQueryFn({
all: (sql, parameters = []) => getStmt(sql).all(...parameters),
run: (sql, parameters = []) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...parameters);
return {
insertId: parseBigInt(lastInsertRowid),
numAffectedRows: parseBigInt(changes),
};
},
}),
close: () => db.close(),
iterator: (isSelect, sql, parameters = []) => {
if (!isSelect) {
throw new Error("Only support select in stream()");
}
return getStmt(sql).iterate(...parameters) as any;
},
};
}
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
let database: DatabaseSync;
if (config) {
if ("database" in config) {
database = config.database;
} else {
database = new DatabaseSync(config.url);
}
} else {
database = new DatabaseSync(":memory:");
}
return new GenericSqliteConnection(database, () => nodeSqliteExecutor(database), {
name: "node-sqlite",
});
}

View File

@@ -0,0 +1,11 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe, test, expect } from "vitest";
describe("NodeSqliteConnection", () => {
connectionTestSuite({ describe, test, expect } as any, {
makeConnection: () => nodeSqlite({ database: new DatabaseSync(":memory:") }),
rawDialectDetails: [],
});
});

View File

@@ -1,18 +1,3 @@
import { registries } from "bknd";
import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
export * from "./node.adapter"; export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig }; export * from "./storage";
export * from "./connection/NodeSqliteConnection";
let registered = false;
export function registerLocalMediaAdapter() {
if (!registered) {
registries.media.register("local", StorageLocalAdapter);
registered = true;
}
return (config: Partial<LocalAdapterConfig> = {}) => {
const adapter = new StorageLocalAdapter(config);
return adapter.toJSON(true);
};
}

View File

@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe } from "bun:test"; import { afterAll, beforeAll, describe } from "bun:test";
import * as node from "./node.adapter"; import { createApp, createHandler } from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
@@ -9,7 +9,7 @@ afterAll(enableConsoleLog);
describe("node adapter (bun)", () => { describe("node adapter (bun)", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: node.createApp, makeApp: createApp,
makeHandler: node.createHandler, makeHandler: createHandler,
}); });
}); });

View File

@@ -1,10 +1,11 @@
import path from "node:path"; 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/index"; import { registerLocalMediaAdapter } from "adapter/node/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core"; import { config as $config } from "bknd/core";
import { $console } from "core"; import { $console } from "core";
import type { App } from "App";
type NodeEnv = NodeJS.ProcessEnv; type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & { export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
@@ -45,8 +46,11 @@ export function createHandler<Env = NodeEnv>(
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions, opts?: RuntimeOptions,
) { ) {
let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts); if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
}
return app.fetch(req); return app.fetch(req);
}; };
} }

View File

@@ -1,14 +1,14 @@
import { describe, before, after } from "node:test"; 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 { nodeTestRunner } from "adapter/node/test"; import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
before(() => disableConsoleLog()); beforeAll(() => disableConsoleLog());
after(enableConsoleLog); afterAll(enableConsoleLog);
describe("node adapter", () => { describe("node adapter", () => {
adapterTestSuite(nodeTestRunner, { adapterTestSuite(viTestRunner, {
makeApp: node.createApp, makeApp: node.createApp,
makeHandler: node.createHandler, makeHandler: node.createHandler,
}); });

View File

@@ -1,5 +1,5 @@
import { describe } from "node:test"; import { describe } from "vitest";
import { nodeTestRunner } from "adapter/node/test"; 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";
@@ -14,5 +14,5 @@ describe("StorageLocalAdapter (node)", async () => {
path: path.join(basePath, "tmp"), path: path.join(basePath, "tmp"),
}); });
await adapterTestSuite(nodeTestRunner, adapter, file); await adapterTestSuite(viTestRunner, adapter, file);
}); });

View File

@@ -0,0 +1,17 @@
import { registries } from "bknd";
import { type LocalAdapterConfig, StorageLocalAdapter } from "./StorageLocalAdapter";
export * from "./StorageLocalAdapter";
let registered = false;
export function registerLocalMediaAdapter() {
if (!registered) {
registries.media.register("local", StorageLocalAdapter);
registered = true;
}
return (config: Partial<LocalAdapterConfig> = {}) => {
const adapter = new StorageLocalAdapter(config);
return adapter.toJSON(true);
};
}

View File

@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict"; import nodeAssert from "node:assert/strict";
import { test } from "node:test"; import { test, describe } 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
@@ -85,6 +85,7 @@ nodeTest.skipIf = (condition: boolean): Test => {
}; };
export const nodeTestRunner: TestRunner = { export const nodeTestRunner: TestRunner = {
describe,
test: nodeTest, test: nodeTest,
mock: createMockFunction, mock: createMockFunction,
expect: <T = unknown>(actual?: T, failMsg?: string) => ({ expect: <T = unknown>(actual?: T, failMsg?: string) => ({

View File

@@ -0,0 +1,50 @@
import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
}
vitestTest.if = (condition: boolean): Test => {
if (condition) {
return vitestTest;
}
return (() => {}) as any;
};
vitestTest.skip = (label: string, fn: TestFn) => {
return test.skip(label, fn as any);
};
vitestTest.skipIf = (condition: boolean): Test => {
if (condition) {
return (() => {}) as any;
}
return vitestTest;
};
const vitestExpect = <T = unknown>(actual: T, parentFailMsg?: string) => {
return {
toEqual: (expected: T, failMsg = parentFailMsg) => {
expect(actual, failMsg).toEqual(expected);
},
toBe: (expected: T, failMsg = parentFailMsg) => {
expect(actual, failMsg).toBe(expected);
},
toBeString: () => expect(typeof actual, parentFailMsg).toBe("string"),
toBeUndefined: () => expect(actual, parentFailMsg).toBeUndefined(),
toBeDefined: () => expect(actual, parentFailMsg).toBeDefined(),
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg = parentFailMsg) => {
const e = Array.isArray(expected) ? expected : [expected];
expect(actual, failMsg).toBeOneOf(e);
},
toHaveBeenCalled: () => expect(actual, parentFailMsg).toHaveBeenCalled(),
toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => {
expect(actual, failMsg).toHaveBeenCalledTimes(expected);
},
};
};
export const viTestRunner: TestRunner = {
describe,
test: vitestTest,
expect: vitestExpect as any,
mock: (fn) => vi.fn(fn),
};

View File

@@ -0,0 +1,6 @@
import type { Connection } from "bknd/data";
import { bunSqlite } from "../bun/connection/BunSqliteConnection";
export function sqlite(config: { url: string }): Connection {
return bunSqlite(config);
}

View File

@@ -0,0 +1,6 @@
import type { Connection } from "bknd/data";
import { libsql } from "../../data/connection/sqlite/LibsqlConnection";
export function sqlite(config: { url: string }): Connection {
return libsql(config);
}

View File

@@ -0,0 +1,6 @@
import type { Connection } from "bknd/data";
import { nodeSqlite } from "../node/connection/NodeSqliteConnection";
export function sqlite(config: { url: string }): Connection {
return nodeSqlite(config);
}

View File

@@ -0,0 +1,3 @@
import type { Connection } from "bknd/data";
export type SqliteConnection = (config: { url: string }) => Connection;

View File

@@ -1,6 +1,5 @@
import path from "node:path"; import path from "node:path";
import type { Config } from "@libsql/client/node"; import { $console } from "core";
import { $console, config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import open from "open"; import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys"; import { fileExists, getRelativeDistPath } from "../../utils/sys";
@@ -27,10 +26,6 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
} }
} }
export async function attachServeStatic(app: any, platform: Platform) {
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
}
export async function startServer( export async function startServer(
server: Platform, server: Platform,
app: App, app: App,

View File

@@ -1,6 +1,6 @@
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { App, type CreateAppConfig } from "App"; import type { App, CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node"; 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 { colorizeConsole, config } from "core"; import { colorizeConsole, config } from "core";
@@ -11,19 +11,19 @@ import path from "node:path";
import { import {
PLATFORMS, PLATFORMS,
type Platform, type Platform,
attachServeStatic,
getConfigPath, getConfigPath,
getConnectionCredentialsFromEnv, getConnectionCredentialsFromEnv,
serveStatic,
startServer, startServer,
} from "./platform"; } from "./platform";
import { makeConfig } from "adapter"; import { createRuntimeApp, makeConfig } from "adapter";
import { isBun as $isBun } from "cli/utils/sys"; import { isBun } from "core/utils";
const env_files = [".env", ".dev.vars"]; const env_files = [".env", ".dev.vars"];
dotenv.config({ dotenv.config({
path: env_files.map((file) => path.resolve(process.cwd(), file)), path: env_files.map((file) => path.resolve(process.cwd(), file)),
}); });
const isBun = $isBun(); const is_bun = isBun();
export const run: CliCommand = (program) => { export const run: CliCommand = (program) => {
program program
@@ -44,15 +44,14 @@ export const run: CliCommand = (program) => {
) )
.addOption(new Option("-c, --config <config>", "config file")) .addOption(new Option("-c, --config <config>", "config file"))
.addOption( .addOption(
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts( new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
"config", "config",
), ),
) )
.addOption(new Option("--db-token <db>", "database token").conflicts("config"))
.addOption( .addOption(
new Option("--server <server>", "server type") new Option("--server <server>", "server type")
.choices(PLATFORMS) .choices(PLATFORMS)
.default(isBun ? "bun" : "node"), .default(is_bun ? "bun" : "node"),
) )
.addOption(new Option("--no-open", "don't open browser window on start")) .addOption(new Option("--no-open", "don't open browser window on start"))
.action(action); .action(action);
@@ -72,23 +71,9 @@ type MakeAppConfig = {
}; };
async function makeApp(config: MakeAppConfig) { async function makeApp(config: MakeAppConfig) {
const app = App.create({ connection: config.connection }); return await createRuntimeApp({
serveStatic: await serveStatic(config.server?.platform ?? "node"),
app.emgr.onEvent( });
App.Events.AppBuiltEvent,
async () => {
if (config.onBuilt) {
await config.onBuilt(app);
}
await attachServeStatic(app, config.server?.platform ?? "node");
app.registerAdminController();
},
"sync",
);
await app.build();
return app;
} }
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
@@ -104,7 +89,6 @@ type RunOptions = {
memory?: boolean; memory?: boolean;
config?: string; config?: string;
dbUrl?: string; dbUrl?: string;
dbToken?: string;
server: Platform; server: Platform;
open?: boolean; open?: boolean;
}; };
@@ -116,9 +100,7 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
// first start from arguments if given // first start from arguments if given
if (options.dbUrl) { if (options.dbUrl) {
console.info("Using connection from", c.cyan("--db-url")); console.info("Using connection from", c.cyan("--db-url"));
const connection = options.dbUrl const connection = options.dbUrl ? { url: options.dbUrl } : undefined;
? { url: options.dbUrl, authToken: options.dbToken }
: undefined;
app = await makeApp({ connection, server: { platform: options.server } }); app = await makeApp({ connection, server: { platform: options.server } });
// check configuration file to be present // check configuration file to be present

View File

@@ -11,7 +11,7 @@ import type { CliCommand } from "cli/types";
import { Argument } from "commander"; import { Argument } from "commander";
import { $console } from "core"; import { $console } from "core";
import c from "picocolors"; import c from "picocolors";
import { isBun } from "cli/utils/sys"; import { isBun } from "core/utils";
export const user: CliCommand = (program) => { export const user: CliCommand = (program) => {
program program

View File

@@ -4,14 +4,6 @@ import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
export function isBun(): boolean {
try {
return typeof Bun !== "undefined";
} catch (e) {
return false;
}
}
export function getRootPath() { export function getRootPath() {
const _path = path.dirname(url.fileURLToPath(import.meta.url)); const _path = path.dirname(url.fileURLToPath(import.meta.url));
// because of "src", local needs one more level up // because of "src", local needs one more level up

View File

@@ -16,6 +16,7 @@ export interface Test {
skipIf: (condition: boolean) => (label: string, fn: TestFn) => void; skipIf: (condition: boolean) => (label: string, fn: TestFn) => void;
} }
export type TestRunner = { export type TestRunner = {
describe: (label: string, asyncFn: () => Promise<void>) => void;
test: Test; test: Test;
mock: <T extends (...args: any[]) => any>(fn: T) => T | any; mock: <T extends (...args: any[]) => any>(fn: T) => T | any;
expect: <T = unknown>( expect: <T = unknown>(

View File

@@ -0,0 +1,12 @@
import { createApp as createAppInternal, type CreateAppConfig } from "App";
import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection";
import { Connection } from "data/connection/Connection";
export { App } from "App";
export function createApp({ connection, ...config }: CreateAppConfig = {}) {
return createAppInternal({
...config,
connection: Connection.isConnection(connection) ? connection : bunSqlite(connection as any),
});
}

View File

@@ -48,6 +48,14 @@ export function isNode() {
} }
} }
export function isBun() {
try {
return typeof Bun !== "undefined";
} catch (e) {
return false;
}
}
export function invariant(condition: boolean | any, message: string) { export function invariant(condition: boolean | any, message: string) {
if (!condition) { if (!condition) {
throw new Error(message); throw new Error(message);

View File

@@ -1,5 +1,5 @@
import type { DB } from "core"; import type { DB } from "core";
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; import type { EntityData, RepoQueryIn, RepositoryResultJSON } from "data";
import type { Insertable, Selectable, Updateable } from "kysely"; import type { Insertable, Selectable, Updateable } from "kysely";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
import type { FetchPromise, ResponseObject } from "modules/ModuleApi"; import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
@@ -32,10 +32,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}, query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {},
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>( return this.get<RepositoryResultJSON<Data>>(["entity", entity as any, id], query);
["entity", entity as any, id],
query,
);
} }
readOneBy<E extends keyof DB | string>( readOneBy<E extends keyof DB | string>(
@@ -43,7 +40,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {}, query: Omit<RepoQueryIn, "limit" | "offset" | "sort"> = {},
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = Pick<RepositoryResponse<Data>, "meta" | "data">; type T = RepositoryResultJSON<Data>;
return this.readMany(entity, { return this.readMany(entity, {
...query, ...query,
limit: 1, limit: 1,
@@ -53,7 +50,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) { readMany<E extends keyof DB | string>(entity: E, query: RepoQueryIn = {}) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">; type T = RepositoryResultJSON<Data[]>;
const input = query ?? this.options.defaultQuery; const input = query ?? this.options.defaultQuery;
const req = this.get<T>(["entity", entity as any], input); const req = this.get<T>(["entity", entity as any], input);
@@ -72,7 +69,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
query: RepoQueryIn = {}, query: RepoQueryIn = {},
) { ) {
type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData; type Data = R extends keyof DB ? Selectable<DB[R]> : EntityData;
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>( return this.get<RepositoryResultJSON<Data[]>>(
["entity", entity as any, id, reference], ["entity", entity as any, id, reference],
query ?? this.options.defaultQuery, query ?? this.options.defaultQuery,
); );
@@ -83,7 +80,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
input: Insertable<Input>, input: Insertable<Input>,
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input); return this.post<RepositoryResultJSON<Data>>(["entity", entity as any], input);
} }
createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>( createMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
@@ -94,7 +91,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
throw new Error("input is required"); throw new Error("input is required");
} }
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.post<RepositoryResponse<Data[]>>(["entity", entity as any], input); return this.post<RepositoryResultJSON<Data[]>>(["entity", entity as any], input);
} }
updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>( updateOne<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
@@ -104,7 +101,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
) { ) {
if (!id) throw new Error("ID is required"); if (!id) throw new Error("ID is required");
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input); return this.patch<RepositoryResultJSON<Data>>(["entity", entity as any, id], input);
} }
updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>( updateMany<E extends keyof DB | string, Input = E extends keyof DB ? DB[E] : EntityData>(
@@ -114,7 +111,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
) { ) {
this.requireObjectSet(where); this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.patch<RepositoryResponse<Data[]>>(["entity", entity as any], { return this.patch<RepositoryResultJSON<Data[]>>(["entity", entity as any], {
update, update,
where, where,
}); });
@@ -123,24 +120,24 @@ export class DataApi extends ModuleApi<DataApiOptions> {
deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) { deleteOne<E extends keyof DB | string>(entity: E, id: PrimaryFieldType) {
if (!id) throw new Error("ID is required"); if (!id) throw new Error("ID is required");
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]); return this.delete<RepositoryResultJSON<Data>>(["entity", entity as any, id]);
} }
deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) { deleteMany<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"]) {
this.requireObjectSet(where); this.requireObjectSet(where);
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
return this.delete<RepositoryResponse<Data>>(["entity", entity as any], where); return this.delete<RepositoryResultJSON<Data>>(["entity", entity as any], where);
} }
count<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) { count<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>( return this.post<RepositoryResultJSON<{ entity: E; count: number }>>(
["entity", entity as any, "fn", "count"], ["entity", entity as any, "fn", "count"],
where, where,
); );
} }
exists<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) { exists<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; exists: boolean }>>( return this.post<RepositoryResultJSON<{ entity: E; exists: boolean }>>(
["entity", entity as any, "fn", "exists"], ["entity", entity as any, "fn", "exists"],
where, where,
); );

View File

@@ -3,9 +3,7 @@ import {
DataPermissions, DataPermissions,
type EntityData, type EntityData,
type EntityManager, type EntityManager,
type MutatorResponse,
type RepoQuery, type RepoQuery,
type RepositoryResponse,
repoQuery, repoQuery,
} from "data"; } from "data";
import type { Handler } from "hono/types"; import type { Handler } from "hono/types";
@@ -32,33 +30,6 @@ export class DataController extends Controller {
return this.ctx.guard; return this.ctx.guard;
} }
repoResult<T extends RepositoryResponse<any> = RepositoryResponse>(
res: T,
): Pick<T, "meta" | "data"> {
let meta: Partial<RepositoryResponse["meta"]> = {};
if ("meta" in res) {
const { query, ...rest } = res.meta;
meta = rest;
if (isDebug()) meta.query = query;
}
const template = { data: res.data, meta };
// @todo: this works but it breaks in FE (need to improve DataTable)
// filter empty
return Object.fromEntries(
Object.entries(template).filter(([_, v]) => typeof v !== "undefined" && v !== null),
) as any;
}
mutatorResult(res: MutatorResponse | MutatorResponse<EntityData>) {
const template = { data: res.data };
// filter empty
return Object.fromEntries(Object.entries(template).filter(([_, v]) => v !== undefined));
}
entityExists(entity: string) { entityExists(entity: string) {
try { try {
return !!this.em.entity(entity); return !!this.em.entity(entity);
@@ -257,7 +228,7 @@ export class DataController extends Controller {
const where = c.req.valid("json") as any; const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).count(where); const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count }); return c.json({ entity, ...result.data });
}, },
); );
@@ -279,7 +250,7 @@ export class DataController extends Controller {
const where = c.req.valid("json") as any; const where = c.req.valid("json") as any;
const result = await this.em.repository(entity).exists(where); const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists }); return c.json({ entity, ...result.data });
}, },
); );
@@ -318,7 +289,7 @@ export class DataController extends Controller {
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findMany(options); const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -347,7 +318,7 @@ export class DataController extends Controller {
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(id, options); const result = await this.em.repository(entity).findId(id, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -380,7 +351,7 @@ export class DataController extends Controller {
.repository(entity) .repository(entity)
.findManyByReference(id, reference, options); .findManyByReference(id, reference, options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -414,7 +385,7 @@ export class DataController extends Controller {
const options = (await c.req.json()) as RepoQuery; const options = (await c.req.json()) as RepoQuery;
const result = await this.em.repository(entity).findMany(options); const result = await this.em.repository(entity).findMany(options);
return c.json(this.repoResult(result), { status: result.data ? 200 : 404 }); return c.json(result, { status: result.data ? 200 : 404 });
}, },
); );
@@ -440,11 +411,11 @@ export class DataController extends Controller {
if (Array.isArray(body)) { if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body); const result = await this.em.mutator(entity).insertMany(body);
return c.json(this.mutatorResult(result), 201); return c.json(result, 201);
} }
const result = await this.em.mutator(entity).insertOne(body); const result = await this.em.mutator(entity).insertOne(body);
return c.json(this.mutatorResult(result), 201); return c.json(result, 201);
}, },
); );
@@ -475,7 +446,7 @@ export class DataController extends Controller {
}; };
const result = await this.em.mutator(entity).updateWhere(update, where); const result = await this.em.mutator(entity).updateWhere(update, where);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );
@@ -497,7 +468,7 @@ export class DataController extends Controller {
const body = (await c.req.json()) as EntityData; const body = (await c.req.json()) as EntityData;
const result = await this.em.mutator(entity).updateOne(id, body); const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );
@@ -517,7 +488,7 @@ export class DataController extends Controller {
} }
const result = await this.em.mutator(entity).deleteOne(id); const result = await this.em.mutator(entity).deleteOne(id);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );
@@ -539,7 +510,7 @@ export class DataController extends Controller {
const where = (await c.req.json()) as RepoQuery["where"]; const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where); const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result)); return c.json(result);
}, },
); );

View File

@@ -2,12 +2,15 @@ import {
type AliasableExpression, type AliasableExpression,
type ColumnBuilderCallback, type ColumnBuilderCallback,
type ColumnDataType, type ColumnDataType,
type Compilable,
type CompiledQuery,
type DatabaseIntrospector, type DatabaseIntrospector,
type Dialect, type Dialect,
type Expression, type Expression,
type Kysely, type Kysely,
type KyselyPlugin, type KyselyPlugin,
type OnModifyForeignAction, type OnModifyForeignAction,
type QueryResult,
type RawBuilder, type RawBuilder,
type SelectQueryBuilder, type SelectQueryBuilder,
type SelectQueryNode, type SelectQueryNode,
@@ -15,7 +18,8 @@ import {
sql, sql,
} from "kysely"; } from "kysely";
import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector"; import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector";
import type { Constructor } from "core"; import type { Constructor, DB } from "core";
import { KyselyPluginRunner } from "data/plugins/KyselyPluginRunner";
export type QB = SelectQueryBuilder<any, any, any>; export type QB = SelectQueryBuilder<any, any, any>;
@@ -75,22 +79,44 @@ export type DbFunctions = {
>; >;
}; };
const CONN_SYMBOL = Symbol.for("bknd:connection"); export type ConnQuery = CompiledQuery | Compilable;
export abstract class Connection<DB = any> { export type ConnQueryResult<T extends ConnQuery> = T extends CompiledQuery<infer R>
protected initialized = false; ? QueryResult<R>
kysely: Kysely<DB>; : T extends Compilable<infer R>
protected readonly supported = { ? QueryResult<R>
batching: false, : never;
export type ConnQueryResults<T extends ConnQuery[]> = {
[K in keyof T]: ConnQueryResult<T[K]>;
}; };
const CONN_SYMBOL = Symbol.for("bknd:connection");
export type Features = {
batching: boolean;
softscans: boolean;
};
export abstract class Connection<Client = unknown> {
abstract name: string;
protected initialized = false;
protected pluginRunner: KyselyPluginRunner;
protected readonly supported: Partial<Features> = {
batching: false,
softscans: true,
};
kysely: Kysely<DB>;
client!: Client;
constructor( constructor(
kysely: Kysely<DB>, kysely: Kysely<any>,
public fn: Partial<DbFunctions> = {}, public fn: Partial<DbFunctions> = {},
protected plugins: KyselyPlugin[] = [], protected plugins: KyselyPlugin[] = [],
) { ) {
this.kysely = kysely; this.kysely = kysely;
this[CONN_SYMBOL] = true; this[CONN_SYMBOL] = true;
this.pluginRunner = new KyselyPluginRunner(plugins);
} }
// @todo: consider moving constructor logic here, required by sqlocal // @todo: consider moving constructor logic here, required by sqlocal
@@ -121,30 +147,46 @@ export abstract class Connection<DB = any> {
return res.rows.length > 0; return res.rows.length > 0;
} }
protected async batch<Queries extends QB[]>( protected async transformResultRows(result: any[]): Promise<any[]> {
queries: [...Queries], return await this.pluginRunner.transformResultRows(result);
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
throw new Error("Batching not supported");
} }
async batchQuery<Queries extends QB[]>( /**
queries: [...Queries], * Execute a query and return the result including all metadata
): Promise<{ * returned from the dialect.
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>; */
}> { async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
// bypass if no client support return Promise.all(qbs.map(async (qb) => await this.kysely.executeQuery(qb))) as any;
if (!this.supports("batching")) {
const data: any = [];
for (const q of queries) {
const result = await q.execute();
data.push(result);
}
return data;
} }
return await this.batch(queries); async executeQuery<O extends ConnQuery>(qb: O): Promise<ConnQueryResult<O>> {
const res = await this.executeQueries(qb);
return res[0] as any;
}
protected getCompiled(...qbs: ConnQuery[]): CompiledQuery[] {
return qbs.map((qb) => {
if ("compile" in qb) {
return qb.compile();
}
return qb;
});
}
protected async withTransformedRows<
Key extends string = "rows",
O extends { [K in Key]: any[] }[] = [],
>(result: O, _key?: Key): Promise<O> {
return (await Promise.all(
result.map(async (row) => {
const key = _key ?? "rows";
const { [key]: rows, ...r } = row;
return {
...r,
rows: await this.transformResultRows(rows),
};
}),
)) as any;
} }
protected validateFieldSpecType(type: string): type is FieldSpec["type"] { protected validateFieldSpecType(type: string): type is FieldSpec["type"] {

View File

@@ -1,6 +1,8 @@
import { Connection, type FieldSpec, type SchemaResponse } from "./Connection"; import { Connection, type FieldSpec, type SchemaResponse } from "./Connection";
export class DummyConnection extends Connection { export class DummyConnection extends Connection {
override name: string = "dummy";
protected override readonly supported = { protected override readonly supported = {
batching: true, batching: true,
}; };

View File

@@ -0,0 +1,187 @@
import type { TestRunner } from "core/test";
import { Connection, type FieldSpec } from "./Connection";
export function connectionTestSuite(
testRunner: TestRunner,
{
makeConnection,
rawDialectDetails,
}: {
makeConnection: () => Connection;
rawDialectDetails: string[];
},
) {
const { test, expect, describe } = testRunner;
test("pings", async () => {
const connection = makeConnection();
const res = await connection.ping();
expect(res).toBe(true);
});
test("initializes", async () => {
const connection = makeConnection();
await connection.init();
// @ts-expect-error
expect(connection.initialized).toBe(true);
expect(connection.client).toBeDefined();
});
test("isConnection", async () => {
const connection = makeConnection();
expect(Connection.isConnection(connection)).toBe(true);
});
test("getFieldSchema", async () => {
const c = makeConnection();
const specToNode = (spec: FieldSpec) => {
// @ts-expect-error
const schema = c.kysely.schema.createTable("test").addColumn(...c.getFieldSchema(spec));
return schema.toOperationNode();
};
{
// primary
const node = specToNode({
type: "integer",
name: "id",
primary: true,
});
const col = node.columns[0]!;
expect(col.primaryKey).toBe(true);
expect(col.notNull).toBe(true);
}
{
// normal
const node = specToNode({
type: "text",
name: "text",
});
const col = node.columns[0]!;
expect(!col.primaryKey).toBe(true);
expect(!col.notNull).toBe(true);
}
{
// nullable (expect to be same as normal)
const node = specToNode({
type: "text",
name: "text",
nullable: true,
});
const col = node.columns[0]!;
expect(!col.primaryKey).toBe(true);
expect(!col.notNull).toBe(true);
}
});
describe("schema", async () => {
const connection = makeConnection();
const fields = [
{
type: "integer",
name: "id",
primary: true,
},
{
type: "text",
name: "text",
},
{
type: "json",
name: "json",
},
] as const satisfies FieldSpec[];
let b = connection.kysely.schema.createTable("test");
for (const field of fields) {
// @ts-expect-error
b = b.addColumn(...connection.getFieldSchema(field));
}
await b.execute();
// add index
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
test("executes query", async () => {
await connection.kysely
.insertInto("test")
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
.execute();
const expected = { id: 1, text: "test", json: { a: 1 } };
const qb = connection.kysely.selectFrom("test").selectAll();
const res = await connection.executeQuery(qb);
expect(res.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => detail in res)).toBe(true);
{
const res = await connection.executeQueries(qb, qb);
expect(res.length).toBe(2);
res.map((r) => {
expect(r.rows).toEqual([expected]);
expect(rawDialectDetails.every((detail) => detail in r)).toBe(true);
});
}
});
test("introspects", async () => {
const tables = await connection.getIntrospector().getTables({
withInternalKyselyTables: false,
});
const clean = tables.map((t) => ({
...t,
columns: t.columns.map((c) => ({
...c,
dataType: undefined,
})),
}));
expect(clean).toEqual([
{
name: "test",
isView: false,
columns: [
{
name: "id",
dataType: undefined,
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
},
{
name: "text",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
},
{
name: "json",
dataType: undefined,
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
},
],
},
]);
});
expect(await connection.getIntrospector().getIndices()).toEqual([
{
name: "test_index",
table: "test",
isUnique: false,
columns: [
{
name: "id",
order: 0,
},
],
},
]);
});
}

View File

@@ -5,11 +5,13 @@ export {
type IndexSpec, type IndexSpec,
type DbFunctions, type DbFunctions,
type SchemaResponse, type SchemaResponse,
type ConnQuery,
type ConnQueryResults,
customIntrospector, customIntrospector,
} from "./Connection"; } from "./Connection";
// sqlite // sqlite
export { LibsqlConnection, type LibSqlCredentials } from "./sqlite/LibsqlConnection"; //export { libsql, LibsqlConnection, type LibSqlCredentials } from "./sqlite/LibsqlConnection";
export { SqliteConnection } from "./sqlite/SqliteConnection"; export { SqliteConnection } from "./sqlite/SqliteConnection";
export { SqliteIntrospector } from "./sqlite/SqliteIntrospector"; export { SqliteIntrospector } from "./sqlite/SqliteIntrospector";
export { SqliteLocalConnection } from "./sqlite/SqliteLocalConnection"; export { SqliteLocalConnection } from "./sqlite/SqliteLocalConnection";

View File

@@ -0,0 +1,49 @@
import type { KyselyPlugin } from "kysely";
import {
type IGenericSqlite,
type OnCreateConnection,
type Promisable,
parseBigInt,
buildQueryFn,
GenericSqliteDialect,
} from "kysely-generic-sqlite";
import { SqliteConnection } from "./SqliteConnection";
import type { Features } from "../Connection";
export type GenericSqliteConnectionConfig = {
name: string;
additionalPlugins?: KyselyPlugin[];
excludeTables?: string[];
onCreateConnection?: OnCreateConnection;
supports?: Partial<Features>;
};
export { parseBigInt, buildQueryFn, GenericSqliteDialect, type IGenericSqlite };
export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB> {
override name = "generic-sqlite";
constructor(
db: DB,
executor: () => Promisable<IGenericSqlite>,
config?: GenericSqliteConnectionConfig,
) {
super({
dialect: GenericSqliteDialect,
dialectArgs: [executor, config?.onCreateConnection],
additionalPlugins: config?.additionalPlugins,
excludeTables: config?.excludeTables,
});
this.client = db;
if (config?.name) {
this.name = config.name;
}
if (config?.supports) {
for (const [key, value] of Object.entries(config.supports)) {
if (value) {
this.supported[key] = value;
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
import { connectionTestSuite } from "../connection-test-suite";
import { LibsqlConnection } from "./LibsqlConnection";
import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test";
import { createClient } from "@libsql/client";
describe("LibsqlConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => new LibsqlConnection(createClient({ url: ":memory:" })),
rawDialectDetails: ["rowsAffected", "lastInsertRowid"],
});
});

View File

@@ -1,92 +1,62 @@
import { type Client, type Config, type InStatement, createClient } from "@libsql/client"; import type { Client, Config, InStatement } from "@libsql/client";
import { createClient } from "libsql-stateless-easy";
import { LibsqlDialect } from "@libsql/kysely-libsql"; import { LibsqlDialect } from "@libsql/kysely-libsql";
import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin"; import { FilterNumericKeysPlugin } from "data/plugins/FilterNumericKeysPlugin";
import { KyselyPluginRunner } from "data/plugins/KyselyPluginRunner"; import { type ConnQuery, type ConnQueryResults, SqliteConnection } from "bknd/data";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import type { QB } from "../Connection";
import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
import { $console } from "core";
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const; export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
export type LibSqlCredentials = Config & { export type LibSqlCredentials = Config & {
protocol?: (typeof LIBSQL_PROTOCOLS)[number]; protocol?: (typeof LIBSQL_PROTOCOLS)[number];
}; };
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()]; function getClient(clientOrCredentials: Client | LibSqlCredentials): Client {
class CustomLibsqlDialect extends LibsqlDialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["libsql_wasm_func_table"],
plugins,
});
}
}
export class LibsqlConnection extends SqliteConnection {
private client: Client;
protected override readonly supported = {
batching: true,
};
constructor(client: Client);
constructor(credentials: LibSqlCredentials);
constructor(clientOrCredentials: Client | LibSqlCredentials) {
let client: Client;
let batching_enabled = true;
if (clientOrCredentials && "url" in clientOrCredentials) { if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials; let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) { if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
$console.log("changing protocol to", protocol); console.info("changing protocol to", protocol);
const [, rest] = url.split("://"); const [, rest] = url.split("://");
url = `${protocol}://${rest}`; url = `${protocol}://${rest}`;
} }
client = createClient({ url, authToken }); return createClient({ url, authToken });
} else {
client = clientOrCredentials;
} }
const kysely = new Kysely({ return clientOrCredentials as Client;
// @ts-expect-error libsql has type issues }
dialect: new CustomLibsqlDialect({ client }),
plugins, export class LibsqlConnection extends SqliteConnection<Client> {
override name = "libsql";
protected override readonly supported = {
batching: true,
softscans: true,
};
constructor(clientOrCredentials: Client | LibSqlCredentials) {
const client = getClient(clientOrCredentials);
super({
excludeTables: ["libsql_wasm_func_table"],
dialect: LibsqlDialect,
dialectArgs: [{ client }],
additionalPlugins: [new FilterNumericKeysPlugin()],
}); });
super(kysely, {}, plugins);
this.client = client; this.client = client;
this.supported.batching = batching_enabled;
} }
getClient(): Client { override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
return this.client; const compiled = this.getCompiled(...qbs);
} const stms: InStatement[] = compiled.map((q) => {
protected override async batch<Queries extends QB[]>(
queries: [...Queries],
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const stms: InStatement[] = queries.map((q) => {
const compiled = q.compile();
return { return {
sql: compiled.sql, sql: q.sql,
args: compiled.parameters as any[], args: q.parameters as any[],
}; };
}); });
const res = await this.client.batch(stms); return this.withTransformedRows(await this.client.batch(stms)) as any;
// let it run through plugins
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.rows);
data.push(rows);
}
return data;
} }
} }
export function libsql(credentials: LibSqlCredentials): LibsqlConnection {
return new LibsqlConnection(credentials);
}

View File

@@ -1,16 +1,49 @@
import type { ColumnDataType, ColumnDefinitionBuilder, Kysely, KyselyPlugin } from "kysely"; import {
ParseJSONResultsPlugin,
type ColumnDataType,
type ColumnDefinitionBuilder,
type Dialect,
Kysely,
type KyselyPlugin,
} from "kysely";
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite"; import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "../Connection"; import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "../Connection";
import type { Constructor } from "core";
import { customIntrospector } from "../Connection";
import { SqliteIntrospector } from "./SqliteIntrospector";
export type SqliteConnectionConfig<
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
> = {
excludeTables?: string[];
dialect: CustomDialect;
dialectArgs?: ConstructorParameters<CustomDialect>;
additionalPlugins?: KyselyPlugin[];
customFn?: Partial<DbFunctions>;
};
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
override name = "sqlite";
constructor(config: SqliteConnectionConfig) {
const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
const kysely = new Kysely({
dialect: customIntrospector(dialect, SqliteIntrospector, {
excludeTables,
plugins,
}).create(...dialectArgs),
plugins,
});
export class SqliteConnection extends Connection {
constructor(kysely: Kysely<any>, fn: Partial<DbFunctions> = {}, plugins: KyselyPlugin[] = []) {
super( super(
kysely, kysely,
{ {
...fn,
jsonArrayFrom, jsonArrayFrom,
jsonObjectFrom, jsonObjectFrom,
jsonBuildObject, jsonBuildObject,
...(config.customFn ?? {}),
}, },
plugins, plugins,
); );
@@ -43,7 +76,7 @@ export class SqliteConnection extends Connection {
if (spec.onUpdate) relCol = relCol.onUpdate(spec.onUpdate); if (spec.onUpdate) relCol = relCol.onUpdate(spec.onUpdate);
return relCol; return relCol;
} }
return spec.nullable ? col : col.notNull(); return col;
}, },
] as const; ] as const;
} }

View File

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

View File

@@ -207,8 +207,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
repository<E extends Entity | keyof TBD | string>( repository<E extends Entity | keyof TBD | string>(
entity: E, entity: E,
opts: Omit<RepositoryOptions, "emgr"> = {},
): Repository<TBD, EntitySchema<TBD, E>> { ): Repository<TBD, EntitySchema<TBD, E>> {
return this.repo(entity); return this.repo(entity, opts);
} }
repo<E extends Entity | keyof TBD | string>( repo<E extends Entity | keyof TBD | string>(

View File

@@ -0,0 +1,126 @@
import { isDebug } from "core";
import { pick } from "core/utils";
import type { Connection } from "data/connection";
import type {
Compilable,
CompiledQuery,
QueryResult as KyselyQueryResult,
SelectQueryBuilder,
} from "kysely";
export type ResultHydrator<T = any> = (rows: T[]) => any;
export type ResultOptions<T = any> = {
hydrator?: ResultHydrator<T>;
beforeExecute?: (compiled: CompiledQuery) => void | Promise<void>;
onError?: (error: Error) => void | Promise<void>;
single?: boolean;
};
export type ResultJSON<T = any> = {
data: T;
meta: {
items: number;
time: number;
sql?: string;
parameters?: any[];
[key: string]: any;
};
};
export interface QueryResult<T = any> extends Omit<KyselyQueryResult<T>, "rows"> {
time: number;
items: number;
data: T;
rows: unknown[];
sql: string;
parameters: any[];
count?: number;
total?: number;
}
export class Result<T = unknown> {
results: QueryResult<T>[] = [];
time: number = 0;
constructor(
protected conn: Connection,
protected options: ResultOptions<T> = {},
) {}
get(): QueryResult<T> {
if (!this.results) {
throw new Error("Result not executed");
}
if (Array.isArray(this.results)) {
return (this.results ?? []) as any;
}
return this.results[0] as any;
}
first(): QueryResult<T> {
const res = this.get();
const first = Array.isArray(res) ? res[0] : res;
return first ?? ({} as any);
}
get sql() {
return this.first().sql;
}
get parameters() {
return this.first().parameters;
}
get data() {
if (this.options.single) {
return this.first().data?.[0];
}
return this.first().data ?? [];
}
async execute(qb: Compilable | Compilable[]) {
const qbs = Array.isArray(qb) ? qb : [qb];
for (const qb of qbs) {
const compiled = qb.compile();
await this.options.beforeExecute?.(compiled);
try {
const start = performance.now();
const res = await this.conn.executeQuery(compiled);
this.time = Number.parseFloat((performance.now() - start).toFixed(2));
this.results.push({
...res,
data: this.options.hydrator?.(res.rows as T[]),
items: res.rows.length,
time: this.time,
sql: compiled.sql,
parameters: [...compiled.parameters],
});
} catch (e) {
if (this.options.onError) {
await this.options.onError(e as Error);
} else {
throw e;
}
}
}
return this;
}
protected additionalMetaKeys(): string[] {
return [];
}
toJSON(): ResultJSON<T> {
const { rows, data, ...metaRaw } = this.first();
const keys = isDebug() ? ["items", "time", "sql", "parameters"] : ["items", "time"];
const meta = pick(metaRaw, [...keys, ...this.additionalMetaKeys()] as any);
return {
data: this.data,
meta,
};
}
}

View File

@@ -1,6 +1,8 @@
export * from "./Entity"; export * from "./Entity";
export * from "./EntityManager"; export * from "./EntityManager";
export * from "./Mutator"; export * from "./mutation/Mutator";
export * from "./query/Repository"; export * from "./query/Repository";
export * from "./query/WhereBuilder"; export * from "./query/WhereBuilder";
export * from "./query/WithBuilder"; export * from "./query/WithBuilder";
export * from "./query/RepositoryResult";
export * from "./mutation/MutatorResult";

View File

@@ -1,12 +1,13 @@
import { $console, type DB as DefaultDB, type PrimaryFieldType } from "core"; import { $console, type DB as DefaultDB, type PrimaryFieldType } from "core";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely"; import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
import { type TActionContext, WhereBuilder } from ".."; import { type TActionContext, WhereBuilder } from "../..";
import type { Entity, EntityData, EntityManager } from "../entities"; import type { Entity, EntityData, EntityManager } from "../../entities";
import { InvalidSearchParamsException } from "../errors"; import { InvalidSearchParamsException } from "../../errors";
import { MutatorEvents } from "../events"; import { MutatorEvents } from "../../events";
import { RelationMutator } from "../relations"; import { RelationMutator } from "../../relations";
import type { RepoQuery } from "../server/query"; import type { RepoQuery } from "../../server/query";
import { MutatorResult, type MutatorResultOptions } from "./MutatorResult";
type MutatorQB = type MutatorQB =
| InsertQueryBuilder<any, any, any> | InsertQueryBuilder<any, any, any>
@@ -17,14 +18,6 @@ type MutatorUpdateOrDelete =
| UpdateQueryBuilder<any, any, any, any> | UpdateQueryBuilder<any, any, any, any>
| DeleteQueryBuilder<any, any, any>; | DeleteQueryBuilder<any, any, any>;
export type MutatorResponse<T = EntityData[]> = {
entity: Entity;
sql: string;
parameters: any[];
result: EntityData[];
data: T;
};
export class Mutator< export class Mutator<
TBD extends object = DefaultDB, TBD extends object = DefaultDB,
TB extends keyof TBD = any, TB extends keyof TBD = any,
@@ -103,35 +96,18 @@ export class Mutator<
return validatedData as Given; return validatedData as Given;
} }
protected async many(qb: MutatorQB): Promise<MutatorResponse> { protected async performQuery<T = EntityData[]>(
const entity = this.entity; qb: MutatorQB,
const { sql, parameters } = qb.compile(); opts?: MutatorResultOptions,
): Promise<MutatorResult<T>> {
try { const result = new MutatorResult(this.em, this.entity, {
const result = await qb.execute(); silent: false,
...opts,
const data = this.em.hydrate(entity.name, result) as EntityData[]; });
return (await result.execute(qb)) as any;
return {
entity,
sql,
parameters: [...parameters],
result: result,
data,
};
} catch (e) {
// @todo: redact
$console.error("[Error in query]", sql);
throw e;
}
} }
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> { async insertOne(data: Input): Promise<MutatorResult<Output>> {
const { data, ...response } = await this.many(qb);
return { ...response, data: data[0]! };
}
async insertOne(data: Input): Promise<MutatorResponse<Output>> {
const entity = this.entity; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);
@@ -174,7 +150,7 @@ export class Mutator<
.values(validatedData) .values(validatedData)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.single(query); const res = await this.performQuery(query, { single: true });
await this.emgr.emit( await this.emgr.emit(
new Mutator.Events.MutatorInsertAfter({ entity, data: res.data, changed: validatedData }), new Mutator.Events.MutatorInsertAfter({ entity, data: res.data, changed: validatedData }),
@@ -183,7 +159,7 @@ export class Mutator<
return res as any; return res as any;
} }
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> { async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResult<Output>> {
const entity = this.entity; const entity = this.entity;
if (!id) { if (!id) {
throw new Error("ID must be provided for update"); throw new Error("ID must be provided for update");
@@ -206,7 +182,7 @@ export class Mutator<
.where(entity.id().name, "=", id) .where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.single(query); const res = await this.performQuery(query, { single: true });
await this.emgr.emit( await this.emgr.emit(
new Mutator.Events.MutatorUpdateAfter({ new Mutator.Events.MutatorUpdateAfter({
@@ -220,7 +196,7 @@ export class Mutator<
return res as any; return res as any;
} }
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> { async deleteOne(id: PrimaryFieldType): Promise<MutatorResult<Output>> {
const entity = this.entity; const entity = this.entity;
if (!id) { if (!id) {
throw new Error("ID must be provided for deletion"); throw new Error("ID must be provided for deletion");
@@ -233,7 +209,7 @@ export class Mutator<
.where(entity.id().name, "=", id) .where(entity.id().name, "=", id)
.returning(entity.getSelect()); .returning(entity.getSelect());
const res = await this.single(query); const res = await this.performQuery(query, { single: true });
await this.emgr.emit( await this.emgr.emit(
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }), new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data }),
@@ -286,7 +262,7 @@ export class Mutator<
} }
// @todo: decide whether entries should be deleted all at once or one by one (for events) // @todo: decide whether entries should be deleted all at once or one by one (for events)
async deleteWhere(where: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> { async deleteWhere(where: RepoQuery["where"]): Promise<MutatorResult<Output[]>> {
const entity = this.entity; const entity = this.entity;
// @todo: add a way to delete all by adding force? // @todo: add a way to delete all by adding force?
@@ -298,13 +274,13 @@ export class Mutator<
entity.getSelect(), entity.getSelect(),
); );
return (await this.many(qb)) as any; return await this.performQuery(qb);
} }
async updateWhere( async updateWhere(
data: Partial<Input>, data: Partial<Input>,
where: RepoQuery["where"], where: RepoQuery["where"],
): Promise<MutatorResponse<Output[]>> { ): Promise<MutatorResult<Output[]>> {
const entity = this.entity; const entity = this.entity;
const validatedData = await this.getValidatedData(data, "update"); const validatedData = await this.getValidatedData(data, "update");
@@ -317,10 +293,10 @@ export class Mutator<
.set(validatedData as any) .set(validatedData as any)
.returning(entity.getSelect()); .returning(entity.getSelect());
return (await this.many(query)) as any; return await this.performQuery(query);
} }
async insertMany(data: Input[]): Promise<MutatorResponse<Output[]>> { async insertMany(data: Input[]): Promise<MutatorResult<Output[]>> {
const entity = this.entity; const entity = this.entity;
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) { if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
throw new Error(`Creation of system entity "${entity.name}" is disabled`); throw new Error(`Creation of system entity "${entity.name}" is disabled`);
@@ -352,6 +328,6 @@ export class Mutator<
.values(validated) .values(validated)
.returning(entity.getSelect()); .returning(entity.getSelect());
return (await this.many(query)) as any; return await this.performQuery(query);
} }
} }

View File

@@ -0,0 +1,33 @@
import { $console } from "core/console";
import type { Entity, EntityData } from "../Entity";
import type { EntityManager } from "../EntityManager";
import { Result, type ResultJSON, type ResultOptions } from "../Result";
export type MutatorResultOptions = ResultOptions & {
silent?: boolean;
};
export type MutatorResultJSON<T = EntityData[]> = ResultJSON<T>;
export class MutatorResult<T = EntityData[]> extends Result<T> {
constructor(
protected em: EntityManager<any>,
public entity: Entity,
options?: MutatorResultOptions,
) {
super(em.connection, {
hydrator: (rows) => em.hydrate(entity.name, rows as any),
beforeExecute: (compiled) => {
if (!options?.silent) {
$console.debug(`[Mutation]\n${compiled.sql}\n`);
}
},
onError: (error) => {
if (!options?.silent) {
$console.error("[ERROR] Mutator:", error.message);
}
},
...options,
});
}
}

View File

@@ -13,37 +13,11 @@ import {
WithBuilder, WithBuilder,
} from "../index"; } from "../index";
import { JoinBuilder } from "./JoinBuilder"; import { JoinBuilder } from "./JoinBuilder";
import { ensureInt } from "core/utils"; import { RepositoryResult, type RepositoryResultOptions } from "./RepositoryResult";
import type { ResultOptions } from "../Result";
export type RepositoryQB = SelectQueryBuilder<any, any, any>; export type RepositoryQB = SelectQueryBuilder<any, any, any>;
export type RepositoryRawResponse = {
sql: string;
parameters: any[];
result: EntityData[];
};
export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
entity: Entity;
data: T;
meta: {
items: number;
total?: number;
count?: number;
time?: number;
query?: {
sql: string;
parameters: readonly any[];
};
};
};
export type RepositoryCountResponse = RepositoryRawResponse & {
count: number;
};
export type RepositoryExistsResponse = RepositoryRawResponse & {
exists: boolean;
};
export type RepositoryOptions = { export type RepositoryOptions = {
silent?: boolean; silent?: boolean;
includeCounts?: boolean; includeCounts?: boolean;
@@ -182,126 +156,18 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return validated; return validated;
} }
protected async executeQb(qb: RepositoryQB) { protected async performQuery<T = EntityData[]>(
const compiled = qb.compile(); qb: RepositoryQB,
if (this.options?.silent !== true) { opts?: RepositoryResultOptions,
$console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters); execOpts?: { includeCounts?: boolean },
} ): Promise<RepositoryResult<T>> {
const result = new RepositoryResult(this.em, this.entity, {
let result: any; silent: this.options.silent,
try { ...opts,
result = await qb.execute(); });
} catch (e) { return (await result.execute(qb, {
if (this.options?.silent !== true) { includeCounts: execOpts?.includeCounts ?? this.options.includeCounts,
if (e instanceof Error) { })) as any;
$console.error("[ERROR] Repository.executeQb", e.message);
}
throw e;
}
}
return {
result,
sql: compiled.sql,
parameters: [...compiled.parameters],
};
}
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
const compiled = qb.compile();
const payload = {
entity,
sql: compiled.sql,
parameters: [...compiled.parameters],
result: [],
data: [],
meta: {
total: 0,
count: 0,
items: 0,
time: 0,
query: { sql: compiled.sql, parameters: compiled.parameters },
},
};
// don't batch (add counts) if `includeCounts` is set to false
// or when explicitly set to true and batching is not supported
if (
this.options?.includeCounts === false ||
(this.options?.includeCounts === true && !this.em.connection.supports("batching"))
) {
const start = performance.now();
const res = await this.executeQb(qb);
const time = Number.parseFloat((performance.now() - start).toFixed(2));
const result = res.result ?? [];
const data = this.em.hydrate(entity.name, result);
return {
...payload,
result,
data,
meta: {
...payload.meta,
total: undefined,
count: undefined,
items: data.length,
time,
},
};
}
if (this.options?.silent !== true) {
$console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters);
}
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
const countQuery = qb
.clearSelect()
.select(selector())
.clearLimit()
.clearOffset()
.clearGroupBy()
.clearOrderBy();
const totalQuery = this.conn.selectFrom(entity.name).select(selector());
try {
const start = performance.now();
const [_count, _total, result] = await this.em.connection.batchQuery([
countQuery,
totalQuery,
qb,
]);
const time = Number.parseFloat((performance.now() - start).toFixed(2));
const data = this.em.hydrate(entity.name, result);
return {
...payload,
result,
data,
meta: {
...payload.meta,
// parsing is important since pg returns string
total: ensureInt(_total[0]?.count),
count: ensureInt(_count[0]?.count),
items: result.length,
time,
},
};
} catch (e) {
if (this.options?.silent !== true) {
if (e instanceof Error) {
$console.error("[ERROR] Repository.performQuery", e.message);
}
throw e;
} else {
return payload;
}
}
} }
private async triggerFindBefore(entity: Entity, options: RepoQuery): Promise<void> { private async triggerFindBefore(entity: Entity, options: RepoQuery): Promise<void> {
@@ -319,7 +185,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
): Promise<void> { ): Promise<void> {
if (options.limit === 1) { if (options.limit === 1) {
await this.emgr.emit( await this.emgr.emit(
new Repository.Events.RepositoryFindOneAfter({ entity, options, data: data[0]! }), new Repository.Events.RepositoryFindOneAfter({ entity, options, data }),
); );
} else { } else {
await this.emgr.emit( await this.emgr.emit(
@@ -331,12 +197,11 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
protected async single( protected async single(
qb: RepositoryQB, qb: RepositoryQB,
options: RepoQuery, options: RepoQuery,
): Promise<RepositoryResponse<EntityData>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
await this.triggerFindBefore(this.entity, options); await this.triggerFindBefore(this.entity, options);
const { data, ...response } = await this.performQuery(qb); const result = await this.performQuery(qb, { single: true });
await this.triggerFindAfter(this.entity, options, result.data);
await this.triggerFindAfter(this.entity, options, data); return result as any;
return { ...response, data: data[0]! };
} }
addOptionsToQueryBuilder( addOptionsToQueryBuilder(
@@ -413,7 +278,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
async findId( async findId(
id: PrimaryFieldType, id: PrimaryFieldType,
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
): Promise<RepositoryResponse<TBD[TB] | undefined>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery( const { qb, options } = this.buildQuery(
{ {
..._options, ..._options,
@@ -429,7 +294,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
async findOne( async findOne(
where: RepoQuery["where"], where: RepoQuery["where"],
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>,
): Promise<RepositoryResponse<TBD[TB] | undefined>> { ): Promise<RepositoryResult<TBD[TB] | undefined>> {
const { qb, options } = this.buildQuery({ const { qb, options } = this.buildQuery({
..._options, ..._options,
where, where,
@@ -439,7 +304,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
return (await this.single(qb, options)) as any; return (await this.single(qb, options)) as any;
} }
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> { async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResult<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options); const { qb, options } = this.buildQuery(_options);
await this.triggerFindBefore(this.entity, options); await this.triggerFindBefore(this.entity, options);
@@ -454,7 +319,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
id: PrimaryFieldType, id: PrimaryFieldType,
reference: string, reference: string,
_options?: Partial<Omit<RepoQuery, "limit" | "offset">>, _options?: Partial<Omit<RepoQuery, "limit" | "offset">>,
): Promise<RepositoryResponse<EntityData>> { ): Promise<RepositoryResult<EntityData>> {
const entity = this.entity; const entity = this.entity;
const listable_relations = this.em.relations.listableRelationsOf(entity); const listable_relations = this.em.relations.listableRelationsOf(entity);
const relation = listable_relations.find((r) => r.ref(reference).reference === reference); const relation = listable_relations.find((r) => r.ref(reference).reference === reference);
@@ -482,10 +347,10 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}, },
}; };
return this.cloneFor(newEntity).findMany(findManyOptions); return this.cloneFor(newEntity).findMany(findManyOptions) as any;
} }
async count(where?: RepoQuery["where"]): Promise<RepositoryCountResponse> { async count(where?: RepoQuery["where"]): Promise<RepositoryResult<{ count: number }>> {
const entity = this.entity; const entity = this.entity;
const options = this.getValidOptions({ where }); const options = this.getValidOptions({ where });
@@ -497,17 +362,18 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
qb = WhereBuilder.addClause(qb, options.where); qb = WhereBuilder.addClause(qb, options.where);
} }
const { result, ...compiled } = await this.executeQb(qb); return await this.performQuery(
qb,
return { {
sql: compiled.sql, hydrator: (rows) => ({ count: rows[0]?.count ?? 0 }),
parameters: [...compiled.parameters], },
result, { includeCounts: false },
count: result[0]?.count ?? 0, );
};
} }
async exists(where: Required<RepoQuery>["where"]): Promise<RepositoryExistsResponse> { async exists(
where: Required<RepoQuery>["where"],
): Promise<RepositoryResult<{ exists: boolean }>> {
const entity = this.entity; const entity = this.entity;
const options = this.getValidOptions({ where }); const options = this.getValidOptions({ where });
@@ -517,13 +383,8 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
// add mandatory where // add mandatory where
qb = WhereBuilder.addClause(qb, options.where!).limit(1); qb = WhereBuilder.addClause(qb, options.where!).limit(1);
const { result, ...compiled } = await this.executeQb(qb); return await this.performQuery(qb, {
hydrator: (rows) => ({ exists: rows[0]?.count > 0 }),
return { });
sql: compiled.sql,
parameters: [...compiled.parameters],
result,
exists: result[0]!.count > 0,
};
} }
} }

View File

@@ -0,0 +1,105 @@
import { $console } from "core/console";
import type { Entity, EntityData } from "../Entity";
import type { EntityManager } from "../EntityManager";
import { Result, type ResultJSON, type ResultOptions } from "../Result";
import type { Compilable, SelectQueryBuilder } from "kysely";
import { ensureInt } from "core/utils";
export type RepositoryResultOptions = ResultOptions & {
silent?: boolean;
};
export type RepositoryResultJSON<T = EntityData[]> = ResultJSON<T>;
export class RepositoryResult<T = EntityData[]> extends Result<T> {
constructor(
protected em: EntityManager<any>,
public entity: Entity,
options?: RepositoryResultOptions,
) {
super(em.connection, {
hydrator: (rows) => em.hydrate(entity.name, rows as any),
beforeExecute: (compiled) => {
if (!options?.silent) {
$console.debug(`Query:\n${compiled.sql}\n`, compiled.parameters);
}
},
onError: (error) => {
if (options?.silent !== true) {
$console.error("Repository:", String(error));
throw error;
}
},
...options,
});
}
private shouldIncludeCounts(intent?: boolean) {
if (intent === undefined) return this.conn.supports("softscans");
return intent;
}
override async execute(
qb: SelectQueryBuilder<any, any, any>,
opts?: { includeCounts?: boolean },
) {
const includeCounts = this.shouldIncludeCounts(opts?.includeCounts);
if (includeCounts) {
const selector = (as = "count") => this.conn.kysely.fn.countAll<number>().as(as);
const countQuery = qb
.clearSelect()
.select(selector())
.clearLimit()
.clearOffset()
.clearGroupBy()
.clearOrderBy();
const totalQuery = this.conn.kysely.selectFrom(this.entity.name).select(selector());
const compiled = qb.compile();
this.options.beforeExecute?.(compiled);
try {
const start = performance.now();
const [main, count, total] = await this.em.connection.executeQueries(
compiled,
countQuery,
totalQuery,
);
this.time = Number.parseFloat((performance.now() - start).toFixed(2));
this.results.push({
...main,
data: this.options.hydrator?.(main.rows as T[]),
items: main.rows.length,
count: ensureInt(count.rows[0]?.count ?? 0),
total: ensureInt(total.rows[0]?.count ?? 0),
time: this.time,
sql: compiled.sql,
parameters: [...compiled.parameters],
});
} catch (e) {
if (this.options.onError) {
await this.options.onError(e as Error);
} else {
throw e;
}
}
return this;
}
return await super.execute(qb);
}
get count() {
return this.first().count;
}
get total() {
return this.first().total;
}
protected override additionalMetaKeys(): string[] {
return ["count", "total"];
}
}

View File

@@ -208,7 +208,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
[field.targetField()]: primaryReference as any, [field.targetField()]: primaryReference as any,
}); });
if (!query.exists) { if (!query.data.exists) {
const idProp = field.targetField(); const idProp = field.targetField();
throw new Error( throw new Error(
`Cannot connect "${entity.name}.${key}" to ` + `Cannot connect "${entity.name}.${key}" to ` +

View File

@@ -70,7 +70,7 @@ export class RelationMutator {
[field.targetField()]: value, [field.targetField()]: value,
}); });
if (!query.exists) { if (!query.data.exists) {
const idProp = field.targetField(); const idProp = field.targetField();
throw new Error( throw new Error(
`Cannot connect "${this.entity.name}.${key}" to ` + `Cannot connect "${this.entity.name}.${key}" to ` +

View File

@@ -216,7 +216,9 @@ export class MediaController extends Controller {
const paths_to_delete: string[] = []; const paths_to_delete: string[] = [];
if (max_items) { if (max_items) {
const { overwrite } = c.req.valid("query"); const { overwrite } = c.req.valid("query");
const { count } = await this.media.em.repository(media_entity).count(mediaRef); const {
data: { count },
} = await this.media.em.repository(media_entity).count(mediaRef);
// if there are more than or equal to max items // if there are more than or equal to max items
if (count >= max_items) { if (count >= max_items) {
@@ -255,7 +257,9 @@ export class MediaController extends Controller {
} }
// check if entity exists in database // check if entity exists in database
const { exists } = await this.media.em.repository(entity).exists({ id: entity_id }); const {
data: { exists },
} = await this.media.em.repository(entity).exists({ id: entity_id });
if (!exists) { if (!exists) {
return c.json( return c.json(
{ error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` }, { error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` },

View File

@@ -11,16 +11,9 @@ import {
stripMark, stripMark,
transformObject, transformObject,
} from "core/utils"; } from "core/utils";
import { import type { Connection, Schema } from "data";
type Connection, import { EntityManager } from "data/entities/EntityManager";
EntityManager, import * as proto from "data/prototype";
type Schema,
datetime,
entity,
enumm,
jsonSchema,
number,
} from "data";
import { TransformPersistFailedException } from "data/errors"; import { TransformPersistFailedException } from "data/errors";
import { Hono } from "hono"; import { Hono } from "hono";
import type { Kysely } from "kysely"; import type { Kysely } from "kysely";
@@ -119,12 +112,12 @@ const configJsonSchema = Type.Union([
}), }),
), ),
]); ]);
export const __bknd = entity(TABLE_NAME, { export const __bknd = proto.entity(TABLE_NAME, {
version: number().required(), version: proto.number().required(),
type: enumm({ enum: ["config", "diff", "backup"] }).required(), type: proto.enumm({ enum: ["config", "diff", "backup"] }).required(),
json: jsonSchema({ schema: configJsonSchema }).required(), json: proto.jsonSchema({ schema: configJsonSchema }).required(),
created_at: datetime(), created_at: proto.datetime(),
updated_at: datetime(), updated_at: proto.datetime(),
}); });
type ConfigTable2 = Schema<typeof __bknd>; type ConfigTable2 = Schema<typeof __bknd>;
interface T_INTERNAL_EM { interface T_INTERNAL_EM {
@@ -237,7 +230,8 @@ export class ModuleManager {
} }
private get db() { private get db() {
return this.connection.kysely as Kysely<{ table: ConfigTable }>; // @todo: check why this is neccessary
return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>;
} }
// @todo: add indices for: version, type // @todo: add indices for: version, type

View File

@@ -311,6 +311,11 @@ export class SystemController extends Controller {
c.json({ c.json({
version: c.get("app")?.version(), version: c.get("app")?.version(),
runtime: getRuntimeKey(), runtime: getRuntimeKey(),
connection: {
name: this.app.em.connection.name,
// @ts-expect-error
supports: this.app.em.connection.supported,
},
timezone: { timezone: {
name: getTimezone(), name: getTimezone(),
offset: getTimezoneOffset(), offset: getTimezoneOffset(),

View File

@@ -1,7 +1,7 @@
import type { DB, PrimaryFieldType } from "core"; import type { DB, PrimaryFieldType } from "core";
import { objectTransform } from "core/utils/objects"; import { objectTransform } from "core/utils/objects";
import { encodeSearch } from "core/utils/reqres"; import { encodeSearch } from "core/utils/reqres";
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data"; import type { EntityData, RepoQueryIn, RepositoryResult } from "data";
import type { Insertable, Selectable, Updateable } from "kysely"; import type { Insertable, Selectable, Updateable } from "kysely";
import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi"; import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi";
import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr";
@@ -28,15 +28,13 @@ interface UseEntityReturn<
Entity extends keyof DB | string, Entity extends keyof DB | string,
Id extends PrimaryFieldType | undefined, Id extends PrimaryFieldType | undefined,
Data = Entity extends keyof DB ? DB[Entity] : EntityData, Data = Entity extends keyof DB ? DB[Entity] : EntityData,
Response = ResponseObject<RepositoryResponse<Selectable<Data>>>, Response = ResponseObject<RepositoryResult<Selectable<Data>>>,
> { > {
create: (input: Insertable<Data>) => Promise<Response>; create: (input: Insertable<Data>) => Promise<Response>;
read: ( read: (
query?: RepoQueryIn, query?: RepoQueryIn,
) => Promise< ) => Promise<
ResponseObject< ResponseObject<RepositoryResult<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>>
RepositoryResponse<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>
>
>; >;
update: Id extends undefined update: Id extends undefined
? (input: Updateable<Data>, id: Id) => Promise<Response> ? (input: Updateable<Data>, id: Id) => Promise<Response>

View File

@@ -3,27 +3,33 @@ import { serveStatic } from "@hono/node-server/serve-static";
import { showRoutes } from "hono/dev"; import { showRoutes } from "hono/dev";
import { App, registries } from "./src"; import { App, registries } from "./src";
import { StorageLocalAdapter } from "./src/adapter/node"; import { StorageLocalAdapter } from "./src/adapter/node";
import { EntityManager, LibsqlConnection } from "data"; import type { Connection } from "./src/data/connection/Connection";
import { __bknd } from "modules/ModuleManager"; import { __bknd } from "modules/ModuleManager";
import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
import { libsql } from "./src/data/connection/sqlite/LibsqlConnection";
import { $console } from "core";
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
const example = import.meta.env.VITE_EXAMPLE; const example = import.meta.env.VITE_EXAMPLE;
const dbUrl = example ? `file:.configs/${example}.db` : import.meta.env.VITE_DB_URL;
const credentials = example let connection: Connection;
? { if (dbUrl) {
url: `file:.configs/${example}.db`, connection = nodeSqlite({ url: dbUrl });
$console.debug("Using node-sqlite connection", dbUrl);
} else if (import.meta.env.VITE_DB_LIBSQL_URL) {
connection = libsql({
url: import.meta.env.VITE_DB_LIBSQL_URL!,
authToken: import.meta.env.VITE_DB_LIBSQL_TOKEN!,
});
$console.debug("Using libsql connection", import.meta.env.VITE_DB_URL);
} else {
connection = nodeSqlite();
$console.debug("No connection provided, using in-memory database");
} }
: import.meta.env.VITE_DB_URL
? {
url: import.meta.env.VITE_DB_URL!,
authToken: import.meta.env.VITE_DB_TOKEN!,
}
: {
url: ":memory:",
};
if (example) { /* if (example) {
const { version, ...config } = JSON.parse(await readFile(`.configs/${example}.json`, "utf-8")); const { version, ...config } = JSON.parse(await readFile(`.configs/${example}.json`, "utf-8"));
// create db with config // create db with config
@@ -47,7 +53,7 @@ if (example) {
json: config, json: config,
}); });
} }
} } */
let app: App; let app: App;
const recreate = import.meta.env.VITE_APP_FRESH === "1"; const recreate = import.meta.env.VITE_APP_FRESH === "1";
@@ -57,7 +63,7 @@ export default {
async fetch(request: Request) { async fetch(request: Request) {
if (!app || recreate) { if (!app || recreate) {
app = App.create({ app = App.create({
connection: credentials, connection,
}); });
app.emgr.onEvent( app.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
@@ -74,15 +80,11 @@ export default {
// log routes // log routes
if (firstStart) { if (firstStart) {
firstStart = false; firstStart = false;
// biome-ignore lint/suspicious/noConsoleLog:
console.log("[DB]", credentials);
if (import.meta.env.VITE_SHOW_ROUTES === "1") { if (import.meta.env.VITE_SHOW_ROUTES === "1") {
// biome-ignore lint/suspicious/noConsoleLog: console.info("\n[APP ROUTES]");
console.log("\n[APP ROUTES]");
showRoutes(app.server); showRoutes(app.server);
// biome-ignore lint/suspicious/noConsoleLog: console.info("-------\n");
console.log("-------\n");
} }
} }
} }

View File

@@ -1,18 +1,26 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
import path from "node:path";
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths()], plugins: [tsconfigPaths()],
test: { test: {
globals: true, projects: ["**/*.vitest.config.ts"],
environment: "jsdom", include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
setupFiles: ["./__test__/vitest/setup.ts"],
include: ["**/*.vi-test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts", "**/*.config.ts"],
},
}, },
}); });
// export defineConfig({
// plugins: [tsconfigPaths()],
// test: {
// globals: true,
// environment: "jsdom",
// setupFiles: ["./__test__/vitest/setup.ts"],
// include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
// coverage: {
// provider: "v8",
// reporter: ["text", "json", "html"],
// exclude: ["node_modules/", "**/*.d.ts", "**/*.test.ts", "**/*.config.ts"],
// },
// },
// });

936
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -27,12 +27,13 @@ To serve the API, you can use the `serveLambda` function of the AWS Lambda adapt
```tsx index.mjs ```tsx index.mjs
import { serveLambda } from "bknd/adapter/aws"; import { serveLambda } from "bknd/adapter/aws";
import { libsql } from "bknd/data";
export const handler = serveLambda({ export const handler = serveLambda({
connection: { connection: libsql({
url: process.env.DB_URL!, url: "libsql://your-database-url.turso.io",
authToken: process.env.DB_AUTH_TOKEN! authToken: "your-auth-token",
} }),
}); });
``` ```
Although the runtime would support database as a file, we don't recommend it. You'd need to also bundle the native dependencies which increases the deployment size and cold start time. Instead, we recommend you to use [LibSQL on Turso](/usage/database#sqlite-using-libsql-on-turso). Although the runtime would support database as a file, we don't recommend it. You'd need to also bundle the native dependencies which increases the deployment size and cold start time. Instead, we recommend you to use [LibSQL on Turso](/usage/database#sqlite-using-libsql-on-turso).

View File

@@ -34,8 +34,7 @@ import { serve } from "bknd/adapter/bun";
// if the configuration is omitted, it uses an in-memory database // if the configuration is omitted, it uses an in-memory database
serve({ serve({
connection: { connection: {
url: process.env.DB_URL!, url: "file:data.db"
authToken: process.env.DB_AUTH_TOKEN!
} }
}); });
``` ```

View File

@@ -18,9 +18,7 @@ docker build -t bknd .
If you want to override the bknd version used, you can pass a `VERSION` build argument: If you want to override the bknd version used, you can pass a `VERSION` build argument:
```bash ```bash
docker build --build-arg VERSION=<version> -t bknd . docker build --build-arg VERSION=<version> -t bknd .
```` ```
```bash
## Running the Docker container ## Running the Docker container
To run the Docker container, run the following command: To run the Docker container, run the following command:
@@ -34,10 +32,6 @@ You can pass the same CLI arguments (see [Using the CLI](https://docs.bknd.io/cl
```bash ```bash
docker run -p 1337:1337 -e ARGS="--db-url file:/data/data.db" bknd docker run -p 1337:1337 -e ARGS="--db-url file:/data/data.db" bknd
``` ```
Or connect to a remote turso database:
```bash
docker run -p 1337:1337 -e ARGS="--db-url libsql://<db>.turso.io --db-token <token>" bknd
```
To mount the data directory to the host, you can use the `-v` flag: To mount the data directory to the host, you can use the `-v` flag:
```bash ```bash

View File

@@ -7,6 +7,12 @@ import { Stackblitz, examples } from "/snippets/stackblitz.mdx"
Glad you're here! **bknd** is a lightweight, infrastructure agnostic and feature-rich backend that runs in any JavaScript environment. Glad you're here! **bknd** is a lightweight, infrastructure agnostic and feature-rich backend that runs in any JavaScript environment.
- Instant backend with full REST API
- Built on Web Standards for maximum compatibility
- Multiple run modes (standalone, runtime, framework)
- Official API and React SDK with type-safety
- React elements for auto-configured authentication and media components
## Preview ## Preview
Here is a preview of **bknd** in StackBlitz: Here is a preview of **bknd** in StackBlitz:
<Stackblitz {...examples.adminRich} /> <Stackblitz {...examples.adminRich} />
@@ -96,12 +102,12 @@ The following databases are currently supported. Request a new integration if yo
<CardGroup cols={2}> <CardGroup cols={2}>
<Card <Card
title="LibSQL/SQLite" title="SQLite"
icon={<div className="text-primary-light">{libsql}</div>} icon={<div className="text-primary-light">{sqlite}</div>}
href="/usage/database#database" href="/usage/database#database"
/> />
<Card <Card
title="Turso" title="Turso/LibSQL"
icon={<div className="text-primary-light">{turso}</div>} icon={<div className="text-primary-light">{turso}</div>}
href="/usage/database#sqlite-using-libsql-on-turso" href="/usage/database#sqlite-using-libsql-on-turso"
/> />

View File

@@ -3,53 +3,132 @@ title: 'Database'
description: 'Choosing the right database configuration' description: 'Choosing the right database configuration'
--- ---
In order to use **bknd**, you need to prepare access information to your database and install the dependencies. Connections to the database are managed using Kysely. Therefore, all [its dialects](https://kysely.dev/docs/dialects) are theoretically supported. In order to use **bknd**, you need to prepare access information to your database and potentially install additional dependencies. Connections to the database are managed using Kysely. Therefore, all [its dialects](https://kysely.dev/docs/dialects) are theoretically supported.
Currently supported and tested databases are:
- SQLite (embedded): Node.js SQLite, Bun SQLite, LibSQL, SQLocal
- SQLite (remote): Turso, Cloudflare D1
- Postgres: Vanilla Postgres, Supabase, Neon, Xata
## Database By default, bknd will try to use a SQLite database in-memory. Depending on your runtime, a different SQLite implementation will be used.
### SQLite in-memory
The easiest to get started is using SQLite in-memory. When serving the API in the "Integrations", ## Defining the connection
the function accepts an object with connection details. To use an in-memory database, you can either omit the object completely or explicitly use it as follows: There are mainly 3 ways to define the connection to your database, when
```json 1. creating an app using `App.create()` or `createApp()`
{ 2. creating an app using a [Framework or Runtime adapter](/integration/introduction)
"url": ":memory:" 3. starting a quick instance using the [CLI](/usage/cli#using-configuration-file-bknd-config)
}
When creating an app using `App.create()` or `createApp()`, you can pass a connection object in the configuration object.
```typescript app.ts
import { createApp } from "bknd";
import { sqlite } from "bknd/adapter/sqlite";
// a connection is required when creating an app like this
const app = createApp({
connection: sqlite({ url: ":memory:" }),
});
``` ```
### SQLite as file When using an adapter, or using the CLI, bknd will automatically try to use a SQLite implementation depending on the runtime:
Just like the in-memory option, using a file is just as easy:
```json
{
"url": "file:<path/to/your/database.db>"
}
```
Please note that using SQLite as a file is only supported in server environments.
### SQLite using LibSQL ```javascript app.js
Turso offers a SQLite-fork called LibSQL that runs a server around your SQLite database. To import { serve } from "bknd/adapter/node";
point **bknd** to a local instance of LibSQL, [install Turso's CLI](https://docs.turso.tech/cli/introduction) and run the following command:
```bash serve({
turso dev // connection is optional, but recommended
connection: { url: "file:data.db" },
});
``` ```
The command will yield a URL. Use it in the connection object: You can also pass a connection instance to the `connection` property to explictly use a specific connection.
```json
{ ```javascript app.js
"url": "http://localhost:8080" import { serve } from "bknd/adapter/node";
} import { sqlite } from "bknd/adapter/sqlite";
serve({
connection: sqlite({ url: "file:data.db" }),
});
``` ```
### SQLite using LibSQL on Turso
If you want to use LibSQL on Turso, [sign up for a free account](https://turso.tech/), create a database and point your If you're using [`bknd.config.*`](/extending/config), you can specify the connection on the exported object.
connection object to your new database:
```json ```typescript bknd.config.ts
{ import type { BkndConfig } from "bknd";
"url": "libsql://your-database-url.turso.io",
"authToken": "your-auth-token" export default {
} connection: { url: "file:data.db" },
} as const satisfies BkndConfig;
``` ```
Throughout the documentation, it is assumed you use `bknd.config.ts` to define your connection.
## SQLite
### Using config object
The `sqlite` adapter is automatically resolved based on the runtime.
| Runtime | Adapter | In-Memory | File | Remote |
| ------- | ------- | --------- | ---- | ------ |
| Node.js | `node:sqlite` | ✅ | ✅ | ❌ |
| Bun | `bun:sqlite` | ✅ | ✅ | ❌ |
| Cloudflare Worker/Browser/Edge | `libsql` | 🟠 | 🟠 | ✅ |
The bundled version of the `libsql` connection only works with remote databases. However, you can pass in a `Client` from `@libsql/client`, see [LibSQL](#libsql) for more details.
```typescript bknd.config.ts
import type { BkndConfig } from "bknd";
// no connection is required, bknd will use a SQLite database in-memory
// this does not work on edge environments!
export default {} as const satisfies BkndConfig;
// or explicitly in-memory
export default {
connection: { url: ":memory:" },
} as const satisfies BkndConfig;
// or explicitly as a file
export default {
connection: { url: "file:<path/to/your/database.db>" },
} as const satisfies BkndConfig;
```
### LibSQL
Turso offers a SQLite-fork called LibSQL that runs a server around your SQLite database. The edge-version of the adapter is included in the bundle (remote only):
```typescript bknd.config.ts
import type { BkndConfig } from "bknd";
import { libsql } from "bknd/data";
export default {
connection: libsql({
url: "libsql://your-database-url.turso.io",
authToken: "your-auth-token",
}),
} as const satisfies BkndConfig;
```
If you wish to use LibSQL as file, in-memory or make use of [Embedded Replicas](https://docs.turso.tech/features/embedded-replicas/introduction), you have to pass in the `Client` from `@libsql/client`:
```typescript bknd.config.ts
import type { BkndConfig } from "bknd";
import { libsql } from "bknd/data";
import { createClient } from "@libsql/client";
export default {
connection: libsql(createClient({
url: "libsql://your-database-url.turso.io",
authToken: "your-auth-token",
})),
} as const satisfies BkndConfig;
```
### Cloudflare D1 ### Cloudflare D1
Using the [Cloudflare Adapter](/integration/cloudflare), you can choose to use a D1 database binding. To do so, you only need to add a D1 database to your `wrangler.toml` and it'll pick up automatically. Using the [Cloudflare Adapter](/integration/cloudflare), you can choose to use a D1 database binding. To do so, you only need to add a D1 database to your `wrangler.toml` and it'll pick up automatically.
@@ -63,7 +142,29 @@ export default serve<Env>({
}); });
``` ```
### PostgreSQL
### SQLocal
To use bknd with `sqlocal` for a offline expierence, you need to install the `@bknd/sqlocal` package. You can do so by running the following command:
```bash
npm install @bknd/sqlocal
```
This package uses `sqlocal` under the hood. Consult the [sqlocal documentation](https://sqlocal.dallashoffman.com/guide/setup) for connection options:
```js
import { createApp } from "bknd";
import { SQLocalConnection } from "@bknd/sqlocal";
const app = createApp({
connection: new SQLocalConnection({
databasePath: ":localStorage:",
verbose: true,
})
});
```
## PostgreSQL
To use bknd with Postgres, you need to install the `@bknd/postgres` package. You can do so by running the following command: To use bknd with Postgres, you need to install the `@bknd/postgres` package. You can do so by running the following command:
```bash ```bash
@@ -72,7 +173,7 @@ npm install @bknd/postgres
You can connect to your Postgres database using `pg` or `postgres` dialects. Additionally, you may also define your custom connection. You can connect to your Postgres database using `pg` or `postgres` dialects. Additionally, you may also define your custom connection.
#### Using `pg` ### Using `pg`
To establish a connection to your database, you can use any connection options available on the [`pg`](https://node-postgres.com/apis/client) package. To establish a connection to your database, you can use any connection options available on the [`pg`](https://node-postgres.com/apis/client) package.
@@ -91,7 +192,7 @@ const config = {
serve(config); serve(config);
``` ```
#### Using `postgres` ### Using `postgres`
To establish a connection to your database, you can use any connection options available on the [`postgres`](https://github.com/porsager/postgres) package. To establish a connection to your database, you can use any connection options available on the [`postgres`](https://github.com/porsager/postgres) package.
@@ -104,7 +205,7 @@ serve({
}); });
``` ```
#### Using custom connection ### Using custom connection
Several Postgres hosting providers offer their own clients to connect to their database, e.g. suitable for serverless environments. Several Postgres hosting providers offer their own clients to connect to their database, e.g. suitable for serverless environments.
@@ -148,31 +249,8 @@ serve({
}); });
``` ```
## Custom Connection
### SQLocal Creating a custom connection is as easy as extending the `Connection` class and passing constructing a Kysely instance.
To use bknd with `sqlocal` for a offline expierence, you need to install the `@bknd/sqlocal` package. You can do so by running the following command:
```bash
npm install @bknd/sqlocal
```
This package uses `sqlocal` under the hood. Consult the [sqlocal documentation](https://sqlocal.dallashoffman.com/guide/setup) for connection options:
```js
import { createApp } from "bknd";
import { SQLocalConnection } from "@bknd/sqlocal";
const app = createApp({
connection: new SQLocalConnection({
databasePath: ":localStorage:",
verbose: true,
})
});
```
### Custom Connection
Any bknd app instantiation accepts as connection either `undefined`, a connection object like
described above, or an class instance that extends from `Connection`:
```ts ```ts
import { createApp } from "bknd"; import { createApp } from "bknd";

View File

@@ -4,14 +4,7 @@
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "ALL_TESTS=1 bun test --bail",
"test:coverage": "bun test --coverage",
"types": "bun run --filter './packages/**' types",
"build": "bun run clean:dist && bun run --cwd app build:all && bun build:packages",
"build:packages": "bun run --filter './packages/{cli,plasmic}' build",
"git:pre-commit": "bun run test",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"clean:dist": "find packages -name 'dist' -type d -exec rm -rf {} +",
"ci": "find . -name 'node_modules' -type d -exec rm -rf {} + && bun install", "ci": "find . -name 'node_modules' -type d -exec rm -rf {} + && bun install",
"npm:local": "verdaccio --config verdaccio.yml", "npm:local": "verdaccio --config verdaccio.yml",
"format": "bunx biome format --write ./app", "format": "bunx biome format --write ./app",
@@ -20,26 +13,15 @@
"dependencies": {}, "dependencies": {},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
"@clack/prompts": "^0.10.0",
"@tsconfig/strictest": "^2.0.5", "@tsconfig/strictest": "^2.0.5",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"bun-types": "^1.1.18", "bun-types": "^1.1.18",
"dotenv": "^16.4.5",
"esbuild": "^0.23.0",
"esbuild-plugin-tsc": "^0.4.0",
"miniflare": "^3.20240806.0", "miniflare": "^3.20240806.0",
"mitata": "^0.1.11",
"picocolors": "^1.0.1",
"semver": "^7.6.2",
"sql-formatter": "^15.3.2",
"tsd": "^0.31.1",
"tsup": "^8.1.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"verdaccio": "^5.32.1", "verdaccio": "^5.32.1"
"wrangler": "^3.108.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=22"
}, },
"workspaces": ["app", "packages/*"] "workspaces": ["app", "packages/*"]
} }