Compare commits

..

24 Commits

Author SHA1 Message Date
dswbx 19ca52a25c remove liquid js from package json 2025-04-29 17:50:41 +02:00
dswbx 1ef2287d34 replace LiquidJs rendering with simplified renderer
Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags.
2025-04-29 11:09:58 +02:00
dswbx eec8b64bae refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165)
Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability.
2025-04-26 07:37:28 +02:00
dswbx b60fd01960 fix sync events not awaited (#164) 2025-04-26 07:36:19 +02:00
dswbx f04239332a update imports to adjust nodeTestRunner path and remove unused export (#163)
updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now.
2025-04-26 07:35:57 +02:00
dswbx 3da257e843 update dependencies in package.json (#156)
moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries.
2025-04-24 20:33:51 +02:00
dswbx a762789e93 add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162)
Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage.
2025-04-24 13:59:44 +02:00
dswbx 11f76d788a refactor error handling in authenticator and password strategy (#161)
made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency.
2025-04-24 13:59:33 +02:00
dswbx 3e58a17769 trigger repository-find-[one|many]-[before|after] based on limit (#160) 2025-04-24 13:59:25 +02:00
dswbx a3b33a0312 update admin basepath handling and window context integration (#155)
Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context.
2025-04-24 13:59:18 +02:00
dswbx c7c1811267 Merge remote-tracking branch 'origin/main' into release/0.12
# Conflicts:
#	app/src/ui/elements/auth/AuthForm.tsx
2025-04-22 15:56:40 +02:00
dswbx a1be1b4bf4 modify MediaApi to support custom fetch implementation, defaults to native fetch (#158)
* modify MediaApi to support custom fetch implementation, defaults to native fetch

added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided.

* fix tests and improve api fetcher types
2025-04-22 15:44:55 +02:00
dswbx 5763a6e150 add validation logs and improve data validation handling (#157)
Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation.
2025-04-22 15:44:34 +02:00
dswbx e246396225 Merge remote-tracking branch 'origin/release/0.12' into release/0.12
# Conflicts:
#	app/src/ui/routes/index.tsx
2025-04-20 10:49:28 +02:00
dswbx 8823b94eb7 Merge remote-tracking branch 'origin/main' into release/0.12
# Conflicts:
#	app/__test__/modules/ModuleManager.spec.ts
#	app/src/modules/ModuleManager.ts
2025-04-20 10:48:38 +02:00
dswbx 79ca2a9939 add bcrypt and refactored auth resolve (#147)
* reworked auth architecture with improved password handling and claims

Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly.

* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* refactored auth handling to support bcrypt, extracted user pool

* update email regex to allow '+' and '_' characters

* update test stub password for AppAuth spec

* update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool

* rework strategies to extend a base class instead of interface

* added simple bcrypt test
2025-04-20 10:46:29 +02:00
dswbx edb5d5f4a9 added media permissions (#142)
* added permissions support for media module

introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic.

* fix: handle token absence in getUploadHeaders and add tests for transport modes

ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options.

* remove console.log on DropzoneContainer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-20 09:53:11 +02:00
dswbx 600b55fada Merge pull request #146 from bknd-io/chore/optimize-tb
adjust typebox imports and enable TS incremental
2025-04-11 13:11:35 +02:00
dswbx 95a8289afa merged origin/release/0.12 2025-04-11 13:10:25 +02:00
dswbx a55eca75d0 fix imports in test files
reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes.
2025-04-11 12:02:20 +02:00
dswbx b94dec65d2 ts: enable incremental 2025-04-11 11:54:41 +02:00
dswbx e5d6ed536c Merge pull request #141 from bknd-io/chore/cleanup
cleanup: replace console.log/warn with $console, remove commented-out code
2025-04-10 15:56:13 +02:00
dswbx 4e718a063d cleanup: replace console.log/warn with $console, remove commented-out code
Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage.
2025-04-10 14:18:48 +02:00
dswbx 3859b9ba00 changed tb imports 2025-04-10 10:40:12 +02:00
274 changed files with 5001 additions and 18871 deletions
+1 -6
View File
@@ -12,15 +12,10 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.2.14"
bun-version: "1.2.5"
- name: Install dependencies
working-directory: ./app
+1 -3
View File
@@ -29,6 +29,4 @@ packages/media/.env
.idea
.vscode
.git_old
docker/tmp
.debug
.history
docker/tmp
-1
View File
@@ -1 +0,0 @@
22
+3 -10
View File
@@ -1,4 +1,5 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
@@ -9,30 +10,22 @@
</p>
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
* **Databases**:
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
**For documentation and examples, please visit https://docs.bknd.io.**
> [!WARNING]
> This project requires Node.js 22 or higher (because of `node:sqlite`).
>
> Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Size
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui)
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker).
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker).
## Motivation
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
-42
View File
@@ -1,42 +0,0 @@
# ===== DB Settings =====
VITE_DB_URL=:memory:
# you can set a location for a database here, it'll overwrite the previous setting
# ideally use the ".db" folder (create it first), it's git ignored
VITE_DB_URL=file:.db/dev.db
# alternatively, you can use url/token combination
#VITE_DB_URL=
#VITE_DB_TOKEN=
# ===== DEV Server =====
# restart the dev server on every change (enable with "1")
VITE_APP_FRESH=
# displays react-scan widget (enable with "1")
VITE_DEBUG_RERENDERS=
# console logs registered routes on start (enable with "1")
VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
R2_SECRET_ACCESS_KEY=
R2_URL=
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_S3_URL=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=
# ===== Internals =====
BKND_CLI_CREATE_REF=main
BKND_CLI_LOG_LEVEL=debug
BKND_MODULES_DEBUG=1
+12 -130
View File
@@ -1,9 +1,6 @@
import { afterEach, describe, test, expect } from "bun:test";
import { App, createApp } from "core/test/utils";
import { afterAll, afterEach, describe, expect, test } from "bun:test";
import { App } from "../src";
import { getDummyConnection } from "./helper";
import { Hono } from "hono";
import * as proto from "../src/data/prototype";
import { pick } from "lodash-es";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
@@ -13,133 +10,18 @@ describe("App tests", async () => {
const app = new App(dummyConnection);
await app.build();
expect(await app.em.ping()).toBeTrue();
//expect(await app.data?.em.ping()).toBeTrue();
});
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
initialConfig: {
auth: {
enabled: true,
},
},
options: {
plugins: [
(app) => {
expect(app).toBeDefined();
expect(app).toBeInstanceOf(App);
return {
name: "test",
schema: () => {
called.push("schema");
return proto.em(
{
posts: proto.entity("posts", {
title: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
users: proto.entity("users", {
email_verified: proto.boolean(),
}),
},
(fn, s) => {
fn.relation(s.comments).manyToOne(s.posts);
fn.index(s.posts).on(["title"]);
},
);
},
onBoot: async () => {
called.push("onBoot");
},
beforeBuild: async () => {
called.push("beforeBuild");
},
onBuilt: async () => {
called.push("onBuilt");
},
onServerInit: async (server) => {
called.push("onServerInit");
expect(server).toBeDefined();
expect(server).toBeInstanceOf(Hono);
},
onFirstBoot: async () => {
called.push("onFirstBoot");
},
};
},
],
/*test.only("what", async () => {
const app = new App(dummyConnection, {
auth: {
enabled: true,
},
});
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual(["users", "posts", "comments"]);
expect(app.em.indices.map((i) => i.name)).toEqual([
"idx_unique_users_email",
"idx_users_strategy",
"idx_users_strategy_value",
"idx_posts_title",
]);
expect(
app.em.relations.all.map((r) => pick(r.toJSON(), ["type", "source", "target"])),
).toEqual([
{
type: "n:1",
source: "comments",
target: "posts",
},
]);
expect(called).toEqual([
"onBoot",
"onServerInit",
"beforeBuild",
"onServerInit",
"schema",
"onFirstBoot",
"onBuilt",
]);
expect(app.plugins.size).toBe(1);
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
});
test.only("drivers", async () => {
const called: string[] = [];
const app = new App(dummyConnection, undefined, {
drivers: {
email: {
send: async (to, subject, body) => {
called.push("email.send");
return {
id: "",
};
},
},
cache: {
get: async (key) => {
called.push("cache.get");
return "";
},
set: async (key, value, ttl) => {
called.push("cache.set");
},
del: async (key) => {
called.push("cache.del");
},
},
},
});
await app.build();
expect(app.drivers.cache).toBeDefined();
expect(app.drivers.email).toBeDefined();
await app.drivers.email.send("", "", "");
await app.drivers.cache.get("");
await app.drivers.cache.set("", "", 0);
await app.drivers.cache.del("");
expect(called).toEqual(["email.send", "cache.get", "cache.set", "cache.del"]);
});
await app.module.auth.build();
await app.module.data.build();
console.log(app.em.entities.map((e) => e.name));
console.log(await app.em.schema().getDiff());
});*/
});
+5 -20
View File
@@ -3,36 +3,21 @@ import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { omitKeys } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
it("makes config", () => {
expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({});
expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual(
{},
);
expect(adapter.makeConfig({})).toEqual({});
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({});
// merges everything returned from `app` with the config
expect(
omitKeys(
adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
initialConfig: { server: { cors: { origin: "test" } } },
});
expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({
env: { TEST: "test" },
} as any);
});
/* it.only("...", async () => {
const app = await adapter.createAdapterApp();
}); */
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
+1 -1
View File
@@ -153,7 +153,7 @@ describe("DataApi", () => {
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
const oneByRes = await oneBy;
expect(oneByRes.data).toEqual({ title: "baz" } as any);
expect(oneByRes.body.meta.items).toEqual(1);
expect(oneByRes.body.meta.count).toEqual(1);
});
it("exists/count", async () => {
+1 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils";
import { App, createApp } from "../../src/App";
import * as proto from "../../src/data/prototype";
describe("App", () => {
@@ -20,7 +20,6 @@ describe("App", () => {
"guard",
"flags",
"logger",
"helper",
]);
},
},
+1 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { createApp, registries } from "../../src";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" };
+76 -27
View File
@@ -7,13 +7,13 @@ import {
type EntityData,
EntityManager,
ManyToOneRelation,
type MutatorResponse,
type RepositoryResponse,
TextField,
} from "../../src/data";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
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();
beforeAll(() => disableConsoleLog(["log", "warn"]));
@@ -21,6 +21,52 @@ afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {});
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 () => {
const users = new Entity("users", [
new TextField("name", { required: true }),
@@ -74,7 +120,8 @@ describe("[data] DataController", async () => {
method: "POST",
body: JSON.stringify(_user),
});
const result = (await res.json()) as MutatorResultJSON;
//console.log("res", { _user }, res);
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
@@ -88,7 +135,7 @@ describe("[data] DataController", async () => {
method: "POST",
body: JSON.stringify(_post),
});
const result = (await res.json()) as MutatorResultJSON;
const result = (await res.json()) as MutatorResponse;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
@@ -99,13 +146,13 @@ describe("[data] DataController", async () => {
test("/:entity (read many)", async () => {
const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(3);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3);
expect(data.meta.items).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 () => {
@@ -118,32 +165,33 @@ describe("[data] DataController", async () => {
where: { bio: { $isnull: 1 } },
}),
});
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(1);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1);
expect(data.meta.items).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 () => {
const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(3);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3);
expect(data.meta.items).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 () => {
const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResultJSON<EntityData>;
const data = (await res.json()) as RepositoryResponse<EntityData>;
console.log("data", data);
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(1);
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1);
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
});
@@ -153,7 +201,7 @@ describe("[data] DataController", async () => {
method: "PATCH",
body: JSON.stringify({ name: "new name" }),
});
const { data } = (await res.json()) as MutatorResultJSON;
const { data } = (await res.json()) as MutatorResponse;
expect(res.ok).toBe(true);
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
@@ -161,26 +209,27 @@ describe("[data] DataController", async () => {
test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResultJSON;
const data = (await res.json()) as RepositoryResponse;
console.log("data", data);
//expect(data.meta.total).toBe(2);
//expect(data.meta.count).toBe(1);
expect(data.meta.total).toBe(2);
expect(data.meta.count).toBe(1);
expect(data.meta.items).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 () => {
const res = await app.request("/entity/posts/2", {
method: "DELETE",
});
const { data } = (await res.json()) as RepositoryResultJSON<EntityData>;
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify
const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResultJSON;
//expect(data2.meta.total).toBe(1);
const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1);
});
});
});
@@ -1,18 +1,25 @@
import { describe, test, expect } from "bun:test";
import { getDummyConnection } from "../helper";
import { type WhereQuery, WhereBuilder } from "data";
import { describe, expect, test } from "bun:test";
import { Value, _jsonp } from "../../src/core/utils";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => {
function qb() {
const c = getDummyConnection();
const kysely = c.dummyConnection.kysely;
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
describe("WhereBuilder", () => {
test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -87,4 +94,64 @@ describe("WhereBuilder", () => {
expect(keys).toEqual(expectedKeys);
}
});
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
});
+10 -17
View File
@@ -34,12 +34,19 @@ describe("some tests", async () => {
test("findId", async () => {
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(
'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.data).toBeUndefined();
expect(query.result).toEqual([]);
});
test("findMany", async () => {
@@ -49,7 +56,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 ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.data).toEqual([]);
expect(query.result).toEqual([]);
});
test("findMany with number", async () => {
@@ -59,7 +66,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 ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.data).toEqual([]);
expect(query.result).toEqual([]);
});
test("try adding an existing field name", async () => {
@@ -103,18 +110,4 @@ describe("some tests", async () => {
new EntityManager([entity, entity2], connection);
}).toThrow();
});
test("primary uuid", async () => {
const entity = new Entity("users", [
new PrimaryField("id", { format: "uuid" }),
new TextField("username"),
]);
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
const mutator = em.mutator(entity);
const data = await mutator.insertOne({ username: "test" });
expect(data.data.id).toBeDefined();
expect(data.data.id).toBeString();
});
});
+4 -4
View File
@@ -45,7 +45,7 @@ describe("Mutator simple", async () => {
},
});
expect(query.data).toEqual([{ id: 1, label: "test", count: 1 }]);
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]);
});
test("update inserted row", async () => {
@@ -87,7 +87,7 @@ describe("Mutator simple", async () => {
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
const query2 = await em.repository(items).findId(id);
expect(query2.data).toBeUndefined();
expect(query2.result.length).toBe(0);
});
test("validation: insert incomplete row", async () => {
@@ -177,13 +177,13 @@ describe("Mutator simple", async () => {
});
test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).data.count;
const oldCount = (await em.repo(items).count()).count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).data.count;
const newCount = (await em.repo(items).count()).count;
expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
+11 -5
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Entity, NumberField, TextField } from "data";
import * as p from "data/prototype";
import { Entity, NumberField, TextField } from "../../../src/data";
describe("[data] Entity", async () => {
const entity = new Entity("test", [
@@ -48,7 +47,14 @@ describe("[data] Entity", async () => {
expect(entity.getField("new_field")).toBe(field);
});
test.only("types", async () => {
console.log(entity.toTypes());
});
// @todo: move this to ClientApp
/*test("serialize and deserialize", async () => {
const json = entity.toJSON();
//sconsole.log("json", json.fields);
const newEntity = Entity.deserialize(json);
//console.log("newEntity", newEntity.toJSON().fields);
expect(newEntity).toBeInstanceOf(Entity);
expect(json).toEqual(newEntity.toJSON());
expect(json.fields).toEqual(newEntity.toJSON().fields);
});*/
});
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]);
expect(em.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
+2 -5
View File
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { afterAll, describe, expect, test } from "bun:test";
import type { EventManager } from "../../../src/core/events";
import {
Entity,
@@ -12,14 +12,11 @@ import {
TextField,
} from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"]));
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => {
const entity = new Entity("items", [
new TextField("label", { required: true }),
+123 -31
View File
@@ -26,6 +26,120 @@ async function sleep(ms: number) {
}
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 () => {
const items = new Entity("items", [new TextField("label")]);
const em = new EntityManager([items], dummyConnection);
@@ -46,44 +160,25 @@ describe("[Repository]", async () => {
// count all
const res = await em.repository(items).count();
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);
}
{
const res = await em
.repository(items, {
includeCounts: true,
})
.findMany();
expect(res.count).toBe(3);
}
expect(res.count).toBe(3);
// count filtered
const res2 = await em
.repository(items, {
includeCounts: true,
})
.count({ label: { $in: ["a", "b"] } });
const res2 = await em.repository(items).count({ label: { $in: ["a", "b"] } });
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
expect(res2.parameters).toEqual(["a", "b"]);
expect(res2.data.count).toBe(2);
expect(res2.count).toBe(2);
// check exists
const res3 = await em.repository(items).exists({ label: "a" });
expect(res3.data.exists).toBe(true);
expect(res3.exists).toBe(true);
const res4 = await em.repository(items).exists({ label: "d" });
expect(res4.data.exists).toBe(false);
expect(res4.exists).toBe(false);
// for now, allow empty filter
const res5 = await em.repository(items).exists({});
expect(res5.data.exists).toBe(true);
expect(res5.exists).toBe(true);
});
test("option: silent", async () => {
@@ -96,9 +191,6 @@ describe("[Repository]", async () => {
// should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result
em.repo("items", { silent: true })
.findMany({})
.then((r) => r.data);
expect(
em
.repo("items", { silent: true })
@@ -117,16 +209,16 @@ describe("[Repository]", async () => {
expect(
em
.repo("items", { includeCounts: true })
.repo("items")
.findMany({})
.then((r) => [r.count, r.total]),
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([0, 0]);
expect(
em
.repo("items", { includeCounts: false })
.findMany({})
.then((r) => [r.count, r.total]),
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([undefined, undefined]);
});
});
+8 -8
View File
@@ -89,9 +89,9 @@ describe("[data] WithBuilder", async () => {
const res2 = qb2.compile();
expect(res2.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"',
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"',
);
expect(res2.parameters).toEqual([1]);
expect(res2.parameters).toEqual([1, 0]);
});
test("test with empty join", async () => {
@@ -194,9 +194,9 @@ describe("[data] WithBuilder", async () => {
);
const res = qb.compile();
expect(res.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"',
);
expect(res.parameters).toEqual(["categories.single", 1]);
expect(res.parameters).toEqual(["categories.single", 1, 0]);
const qb2 = WithBuilder.addClause(
em,
@@ -273,9 +273,9 @@ describe("[data] WithBuilder", async () => {
//prettyPrintQb(qb);
expect(qb.compile().sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"',
);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
});
test("compiles with many", async () => {
@@ -315,9 +315,9 @@ describe("[data] WithBuilder", async () => {
);
expect(qb.compile().sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
);
expect(qb.compile().parameters).toEqual([1, 12, 0]);
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
});
test("returns correct result", async () => {
@@ -39,28 +39,4 @@ describe("[data] PrimaryField", async () => {
expect(field.transformPersist(1)).rejects.toThrow();
expect(field.transformRetrieve(1)).toBe(1);
});
test("format", () => {
const uuid = new PrimaryField("uuid", { format: "uuid" });
expect(uuid.format).toBe("uuid");
expect(uuid.fieldType).toBe("text");
expect(uuid.getNewValue()).toBeString();
expect(uuid.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<string>",
import: [{ package: "kysely", name: "Generated" }],
});
const integer = new PrimaryField("integer", { format: "integer" });
expect(integer.format).toBe("integer");
expect(integer.fieldType).toBe("integer");
expect(integer.getNewValue()).toBeUndefined();
expect(integer.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<number>",
import: [{ package: "kysely", name: "Generated" }],
});
});
});
+2 -3
View File
@@ -38,15 +38,14 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" };
}
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
type ConsoleSeverity = "log" | "warn" | "error";
const _oldConsoles = {
debug: console.debug,
log: console.log,
warn: console.warn,
error: console.error,
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { createApp } from "core/test/utils";
import { createApp } from "../../src";
import { Api } from "../../src/Api";
describe("integration config", () => {
+2 -4
View File
@@ -1,8 +1,7 @@
/// <reference types="@types/bun" />
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { createApp, registries } from "../../src";
import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
@@ -44,9 +43,8 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test("accepts direct", async () => {
test.only("accepts direct", async () => {
const app = await makeApp();
console.log("app", app);
const file = Bun.file(path);
const name = makeName("png");
+1 -6
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "core/test/utils";
import { createApp } from "../../src";
import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
@@ -153,7 +153,6 @@ describe("AppAuth", () => {
});
await app.build();
app.registerAdminController();
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
// register custom route
@@ -163,10 +162,6 @@ describe("AppAuth", () => {
await app.server.request("/api/system/ping");
await app.server.request("/test");
expect(spy.mock.calls.length).toBe(0);
// admin route
await app.server.request("/");
expect(spy.mock.calls.length).toBe(1);
});
+1 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { createApp, registries } from "../../src";
import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/modules";
+14 -13
View File
@@ -4,7 +4,6 @@ import { type TSchema, Type } from "@sinclair/typebox";
import { EntityManager, em, entity, index, text } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { Module } from "../../src/modules/Module";
import { ModuleHelper } from "modules/ModuleHelper";
function createModule<Schema extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
@@ -47,9 +46,9 @@ describe("Module", async () => {
}
prt = {
ensureEntity: this.ctx.helper.ensureEntity.bind(this.ctx.helper),
ensureIndex: this.ctx.helper.ensureIndex.bind(this.ctx.helper),
ensureSchema: this.ctx.helper.ensureSchema.bind(this.ctx.helper),
ensureEntity: this.ensureEntity.bind(this),
ensureIndex: this.ensureIndex.bind(this),
ensureSchema: this.ensureSchema.bind(this),
};
get em() {
@@ -64,11 +63,7 @@ describe("Module", async () => {
_em.relations,
_em.indices,
);
const ctx = {
em,
flags: Module.ctx_flags,
};
return new M({} as any, { ...ctx, helper: new ModuleHelper(ctx as any) } as any);
return new M({} as any, { em, flags: Module.ctx_flags } as any);
}
function flat(_em: EntityManager) {
return {
@@ -148,9 +143,14 @@ describe("Module", async () => {
// this should only add the field "important"
m.prt.ensureEntity(
entity("u", {
important: text(),
}),
entity(
"u",
{
important: text(),
},
undefined,
"system",
),
);
expect(m.ctx.flags.sync_required).toBe(true);
@@ -159,7 +159,8 @@ describe("Module", async () => {
{
name: "u",
fields: ["id", "name", "important"],
type: "regular",
// ensured type must be present
type: "system",
},
{
name: "p",
+1 -6
View File
@@ -8,11 +8,10 @@ import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data";
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
import { getDummyConnection } from "../helper";
import { ModuleHelper } from "modules/ModuleHelper";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
const { dummyConnection } = getDummyConnection();
const ctx = {
return {
connection: dummyConnection,
server: new Hono(),
em: new EntityManager([], dummyConnection),
@@ -22,10 +21,6 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
logger: new DebugLogger(false),
...overrides,
};
return {
...ctx,
helper: new ModuleHelper(ctx as any),
} as any;
}
export function moduleTestSuite(module: { new (): Module }) {
-23
View File
@@ -1,23 +0,0 @@
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
import { formatNumber } from "core/utils";
const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"],
target: "node",
outdir: "./dist/cli",
env: "PUBLIC_*",
minify: true,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
for (const output of result.outputs) {
const size_ = await output.text();
console.info(
c.cyan(formatNumber.fileSize(size_.length)),
c.dim(output.path.replace(import.meta.dir + "/", "")),
);
}
+16 -57
View File
@@ -1,6 +1,5 @@
import { $ } from "bun";
import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2);
const watch = args.includes("--watch");
@@ -9,13 +8,8 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) {
console.info("Cleaning dist (w/o static)");
console.log("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
}
@@ -27,11 +21,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
console.info("Types built");
console.log("Types built");
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
console.info("Types aliased");
console.log("Types aliased");
types_running = false;
},
});
@@ -53,21 +47,14 @@ if (types && !watch) {
}
function banner(title: string) {
console.info("");
console.info("=".repeat(40));
console.info(title.toUpperCase());
console.info("-".repeat(40));
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
}
// collection of always-external packages
const external = [
"bun:test",
"node:test",
"node:assert/strict",
"@libsql/client",
"bknd",
/^bknd\/.*/,
] as const;
const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
/**
* Building backend and general API
@@ -78,14 +65,12 @@ async function buildApi() {
minify,
sourcemap,
watch,
define,
entry: [
"src/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/data/index.ts",
"src/media/index.ts",
"src/plugins/index.ts",
],
outDir: "dist",
external: [...external],
@@ -116,7 +101,6 @@ async function buildUi() {
minify,
sourcemap,
watch,
define,
external: [
...external,
"react",
@@ -176,7 +160,6 @@ async function buildUiElements() {
minify,
sourcemap,
watch,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
@@ -228,15 +211,14 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
},
...overrides,
define: {
...define,
__isDev: "0",
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?(hono).*?/,
/^@?(hono|libsql).*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
@@ -253,14 +235,14 @@ async function buildAdapters() {
// specific adatpers
await tsup.build(baseConfig("react-router"));
await tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
}),
);
await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws"));
await tsup.build(baseConfig("cloudflare"));
await tsup.build(
baseConfig("cloudflare", {
external: [/^kysely/],
}),
);
await tsup.build({
...baseConfig("vite"),
@@ -276,29 +258,6 @@ async function buildAdapters() {
...baseConfig("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();
+1 -5
View File
@@ -142,7 +142,6 @@ const adapters = {
},
nextjs: {
dir: path.join(basePath, "examples/nextjs"),
env: "TEST_TIMEOUT=20000",
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .nextjs data.db`;
@@ -196,8 +195,7 @@ async function testAdapter(name: keyof typeof adapters) {
console.log("proc:", proc.pid, "data:", c.cyan(data));
//proc.kill();process.exit(0);
const add_env = "env" in config && config.env ? config.env : "";
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
console.log("DONE!");
while (!proc.killed) {
@@ -207,8 +205,6 @@ async function testAdapter(name: keyof typeof adapters) {
}
}
// run with: TEST_ADAPTER=astro bun run e2e/adapters.ts
// (modify `test:e2e` to `test:e2e:ui` to see the UI)
if (process.env.TEST_ADAPTER) {
await testAdapter(process.env.TEST_ADAPTER as any);
} else {
+16 -60
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.15.0-rc.10",
"version": "0.11.2",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -13,15 +13,12 @@
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun run build.cli.ts",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -34,9 +31,11 @@
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
"test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "vitest run",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest": "vitest run",
"test:vitest:watch": "vitest",
"test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts",
@@ -50,10 +49,9 @@
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
@@ -61,28 +59,26 @@
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8",
"hono": "^4.7.11",
"hono": "^4.7.4",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.1.0",
"kysely": "^0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"swr": "^2.3.3"
"swr": "^2.3.3",
"lodash-es": "^4.17.21",
"@sinclair/typebox": "0.34.30"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@cloudflare/vitest-pool-workers": "^0.8.38",
"@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.3.3",
"@hono/vite-dev-server": "^0.19.1",
"@hono/typebox-validator": "^0.3.2",
"@hono/vite-dev-server": "^0.19.0",
"@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.9",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1",
@@ -103,15 +99,13 @@
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1",
"libsql-stateless-easy": "^1.8.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2",
"picocolors": "^1.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
@@ -124,14 +118,13 @@
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"uuid": "^11.1.0",
"vite": "^6.3.5",
"vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0"
},
"optionalDependencies": {
"@hono/node-server": "^1.14.3"
"@hono/node-server": "^1.13.8"
},
"peerDependencies": {
"react": ">=19",
@@ -186,25 +179,6 @@
"import": "./dist/media/index.js",
"require": "./dist/media/index.js"
},
"./plugins": {
"types": "./dist/types/plugins/index.d.ts",
"import": "./dist/plugins/index.js",
"require": "./dist/plugins/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"
},
"./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
@@ -253,24 +227,6 @@
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
},
"typesVersions": {
"*": {
"data": ["./dist/types/data/index.d.ts"],
"core": ["./dist/types/core/index.d.ts"],
"utils": ["./dist/types/core/utils/index.d.ts"],
"cli": ["./dist/types/cli/index.d.ts"],
"media": ["./dist/types/media/index.d.ts"],
"plugins": ["./dist/types/plugins/index.d.ts"],
"adapter": ["./dist/types/adapter/index.d.ts"],
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
}
},
"publishConfig": {
"access": "public"
},
+1 -2
View File
@@ -3,7 +3,6 @@ import { defineConfig, devices } from "@playwright/test";
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
const timeout = process.env.TEST_TIMEOUT ? Number.parseInt(process.env.TEST_TIMEOUT) : 5000;
export default defineConfig({
testMatch: "**/*.e2e-spec.ts",
@@ -13,7 +12,7 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout,
timeout: 20000,
use: {
baseURL: baseUrl,
trace: "on-first-retry",
+21 -71
View File
@@ -1,11 +1,10 @@
import type { SafeUser } from "auth";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { MediaApi } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser;
@@ -22,24 +21,14 @@ declare global {
}
}
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = {
host?: string;
headers?: Headers;
key?: string;
storage?: {
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
};
onAuthStateChange?: (state: AuthState) => void;
localStorage?: boolean;
fetcher?: ApiFetcher;
verbose?: boolean;
verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & (
| {
token?: string;
@@ -72,18 +61,18 @@ export class Api {
this.verified = options.verified === true;
// prefer request if given
if ("request" in options && options.request) {
if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options && options.token) {
} else if ("token" in options) {
this.token_transport = "header";
this.updateToken(options.token, { trigger: false });
this.updateToken(options.token);
// then check for an user object
} else if ("user" in options && options.user) {
} else if ("user" in options) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
@@ -126,30 +115,16 @@ export class Api {
this.updateToken(headerToken);
return;
}
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
} else if (this.options.localStorage) {
const token = localStorage.getItem(this.tokenKey);
if (token) {
this.token_transport = "header";
this.updateToken(token ? String(token) : undefined);
});
this.updateToken(token);
}
}
}
private get storage() {
if (!this.options.storage) return null;
return {
getItem: async (key: string) => {
return await this.options.storage!.getItem(key);
},
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
}
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
updateToken(token?: string, rebuild?: boolean) {
this.token = token;
this.verified = false;
@@ -159,25 +134,17 @@ export class Api {
this.user = undefined;
}
if (this.storage) {
if (this.options.localStorage) {
const key = this.tokenKey;
if (token) {
this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
localStorage.setItem(key, token);
} else {
this.storage.removeItem(key).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
}
} else {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
localStorage.removeItem(key);
}
}
if (opts?.rebuild) this.buildApis();
if (rebuild) this.buildApis();
}
private markAuthVerified(verfied: boolean) {
@@ -247,32 +214,15 @@ export class Api {
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(
{
...baseParams,
...this.options.data,
},
fetcher,
);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
{
...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth,
onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
fetcher,
);
this.media = new MediaApi(
{
...baseParams,
...this.options.media,
onTokenUpdate: (token) => this.updateToken(token, true),
},
fetcher,
);
this.media = new MediaApi(baseParams, fetcher);
}
}
+51 -100
View File
@@ -1,8 +1,7 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core/utils";
import { $console } from "core";
import { Event } from "core/events";
import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import {
ModuleManager,
@@ -15,29 +14,15 @@ import {
import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
import type { MaybePromise } from "core/types";
import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers";
// biome-ignore format: must be here
import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller";
export type AppPluginConfig = {
name: string;
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
beforeBuild?: () => MaybePromise<void>;
onBuilt?: () => MaybePromise<void>;
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
onFirstBoot?: () => MaybePromise<void>;
onBoot?: () => MaybePromise<void>;
};
export type AppPlugin = (app: App) => AppPluginConfig;
export type AppPlugin = (app: App) => Promise<void> | void;
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent<{
module: string;
config: ModuleConfigs[keyof ModuleConfigs];
}> {
export class AppConfigUpdatedEvent extends AppEvent {
static override slug = "app-config-updated";
}
export class AppBuiltEvent extends AppEvent {
@@ -65,13 +50,16 @@ export type AppOptions = {
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none";
drivers?: {
email?: IEmailDriver;
cache?: ICacheDriver;
};
};
export type CreateAppConfig = {
connection?: Connection | { url: string };
connection?:
| Connection
| {
// @deprecated
type: "libsql";
config: LibSqlCredentials;
}
| LibSqlCredentials;
initialConfig?: InitialModuleConfigs;
options?: AppOptions;
};
@@ -79,40 +67,29 @@ export type CreateAppConfig = {
export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
export class App {
static readonly Events = AppEvents;
modules: ModuleManager;
adminController?: AdminController;
_id: string = crypto.randomUUID();
plugins: Map<string, AppPluginConfig> = new Map();
drivers: Options["drivers"] = {};
private trigger_first_boot = false;
private plugins: AppPlugin[];
private _building: boolean = false;
constructor(
public connection: C,
private connection: Connection,
_initialConfig?: InitialModuleConfigs,
private options?: Options,
private options?: AppOptions,
) {
this.drivers = options?.drivers ?? {};
for (const plugin of options?.plugins ?? []) {
const config = plugin(this);
if (this.plugins.has(config.name)) {
throw new Error(`Plugin ${config.name} already registered`);
}
this.plugins.set(config.name, config);
}
this.runPlugins("onBoot");
this.plugins = options?.plugins ?? [];
this.modules = new ModuleManager(connection, {
...(options?.manager ?? {}),
initial: _initialConfig,
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
onModulesBuilt: this.onModulesBuilt.bind(this),
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
@@ -121,32 +98,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return this.modules.ctx().emgr;
}
protected async runPlugins<Key extends keyof AppPluginConfig>(
key: Key,
...args: any[]
): Promise<{ name: string; result: any }[]> {
const results: { name: string; result: any }[] = [];
for (const [name, config] of this.plugins) {
try {
if (key in config && config[key]) {
const fn = config[key];
if (fn && typeof fn === "function") {
$console.debug(`[Plugin:${name}] ${key}`);
// @ts-expect-error
const result = await fn(...args);
results.push({
name,
result,
});
}
}
} catch (e) {
$console.warn(`[Plugin:${name}] error running "${key}"`, String(e));
}
}
return results as any;
}
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
// prevent multiple concurrent builds
if (this._building) {
@@ -155,8 +106,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
}
if (!options?.forceBuild) return;
}
await this.runPlugins("beforeBuild");
this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true;
@@ -168,10 +117,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
guard.registerPermissions(Object.values(SystemPermissions));
server.route("/api/system", new SystemController(this).getController());
// emit built event
// load plugins
if (this.plugins.length > 0) {
await Promise.all(this.plugins.map((plugin) => plugin(this)));
}
$console.log("App built");
await this.emgr.emit(new AppBuiltEvent({ app: this }));
await this.runPlugins("onBuilt");
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
@@ -199,7 +151,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
}
get fetch(): Hono["fetch"] {
return this.server.fetch as any;
return this.server.fetch;
}
get module() {
@@ -268,16 +220,15 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
$console.log("App config updated", module);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this, module, config }));
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
protected async onFirstBoot() {
async onFirstBoot() {
$console.log("App first boot");
this.trigger_first_boot = true;
await this.runPlugins("onFirstBoot");
}
protected async onServerInit(server: Hono<ServerEnv>) {
async onServerInit(server: Hono<ServerEnv>) {
server.use(async (c, next) => {
c.set("app", this);
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
@@ -302,35 +253,35 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
break;
}
});
// call server init if set
if (this.options?.manager?.onServerInit) {
this.options.manager.onServerInit(server);
}
await this.runPlugins("onServerInit", server);
}
protected async onModulesBuilt(ctx: ModuleBuildContext) {
const results = (await this.runPlugins("schema")) as {
name: string;
result: ReturnType<typeof prototypeEm>;
}[];
if (results.length > 0) {
for (const { name, result } of results) {
if (result) {
$console.log(`[Plugin:${name}] schema`);
ctx.helper.ensureSchema(result);
}
}
}
}
}
export function createApp(config: CreateAppConfig = {}) {
if (!config.connection || !Connection.isConnection(config.connection)) {
let connection: Connection | undefined = undefined;
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");
}
return new App(config.connection, config.initialConfig, config.options);
return new App(connection, config.initialConfig, config.options);
}
+3 -7
View File
@@ -2,11 +2,10 @@
import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { registerLocalMediaAdapter } from ".";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun";
import type { App } from "App";
type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
@@ -34,11 +33,8 @@ export function createHandler<Env = BunEnv>(
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
}
const app = await createApp(config, args ?? (process.env as Env), opts);
return app.fetch(req);
};
}
@@ -76,5 +72,5 @@ export function serve<Env = BunEnv>(
),
});
console.info(`Server is running on http://localhost:${port}`);
console.log(`Server is running on http://localhost:${port}`);
}
@@ -1,15 +0,0 @@
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: () => ({
connection: bunSqlite({ database: new Database(":memory:") }),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
@@ -1,40 +0,0 @@
import { Database } from "bun:sqlite";
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
export type BunSqliteConnection = GenericSqliteConnection<Database>;
export type BunSqliteConnectionConfig = {
database: Database;
};
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
let db: Database;
if (config) {
if ("database" in config) {
db = config.database;
} else {
db = new Database(config.url);
}
} else {
db = new Database(":memory:");
}
return genericSqlite("bun-sqlite", db, (utils) => {
//const fn = cache ? "query" : "prepare";
const getStmt = (sql: string) => db.prepare(sql);
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
});
}
-2
View File
@@ -1,3 +1 @@
export * from "./bun.adapter";
export * from "../node/storage";
export * from "./connection/BunSqliteConnection";
+1 -5
View File
@@ -1,11 +1,7 @@
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
import { expect, test, mock } from "bun:test";
export const bunTestRunner = {
describe,
expect,
test,
mock,
beforeEach,
afterEach,
afterAll,
};
@@ -0,0 +1,63 @@
/// <reference types="@cloudflare/workers-types" />
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
import type { QB } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig = {
binding: D1Database;
};
class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV", "_cf_METADATA"],
});
}
}
export class D1Connection extends SqliteConnection {
protected override readonly supported = {
batching: true,
};
constructor(private config: D1ConnectionConfig) {
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({
dialect: new CustomD1Dialect({ database: config.binding }),
plugins,
});
super(kysely, {}, plugins);
}
get client(): D1Database {
return this.config.binding;
}
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 res = await db.batch(
queries.map((q) => {
const { sql, parameters } = q.compile();
return db.prepare(sql).bind(...parameters);
}),
);
// 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.results);
data.push(rows);
}
return data;
}
}
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig, type CfMakeConfigArgs } from "./config";
import { makeConfig } from "./config";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
@@ -13,38 +13,36 @@ describe("cf adapter", () => {
const DB_URL = ":memory:";
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
request: request ?? (null as any),
env: env ?? { url: DB_URL },
env: env ?? { DB_URL },
ctx: ctx ?? (null as any),
});
it("makes config", async () => {
const staticConfig = makeConfig(
{
connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } },
},
$ctx({ DB_URL }),
);
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined();
expect(
makeConfig(
{
connection: { url: DB_URL },
},
{},
),
).toEqual({ connection: { url: DB_URL } });
const dynamicConfig = makeConfig(
{
app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL },
}),
},
$ctx({ DB_URL }),
);
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined();
expect(
makeConfig(
{
app: (env) => ({
connection: { url: env.DB_URL },
}),
},
{
DB_URL,
},
),
).toEqual({ connection: { url: DB_URL } });
});
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await makeApp(c, { env: a } as any, o);
},
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
makeApp,
makeHandler: (c, a, o) => {
return async (request: any) => {
const app = await makeApp(
@@ -52,7 +50,7 @@ describe("cf adapter", () => {
c ?? {
connection: { url: DB_URL },
},
a!,
a,
o,
);
return app.fetch(request);
@@ -7,15 +7,9 @@ import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import type { App } from "bknd";
import { $console } from "core/utils";
import { $console } from "core";
declare global {
namespace Cloudflare {
interface Env {}
}
}
export type CloudflareEnv = Cloudflare.Env;
export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => {
@@ -23,17 +17,11 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
dobj?: DurableObjectNamespace;
db?: D1Database;
};
d1?: {
session?: boolean;
transport?: "header" | "cookie";
first?: D1SessionConstraint;
};
static?: "kv" | "assets";
key?: string;
keepAliveSeconds?: number;
forceHttps?: boolean;
manifest?: string;
registerMedia?: boolean | ((env: Env) => void);
};
export type Context<Env = CloudflareEnv> = {
+16 -142
View File
@@ -1,176 +1,50 @@
/// <reference types="@cloudflare/workers-types" />
import { registerMedia } from "./storage/StorageR2Adapter";
import { getBinding } from "./bindings";
import { d1Sqlite } from "./connection/D1Connection";
import { Connection } from "bknd/data";
import { D1Connection } from "./D1Connection";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { Context, ExecutionContext } from "hono";
import { $console } from "core/utils";
import { setCookie } from "hono/cookie";
import { sqlite } from "bknd/adapter/sqlite";
import type { ExecutionContext } from "hono";
import { $console } from "core";
export const constants = {
exec_async_event_id: "cf_register_waituntil",
cache_endpoint: "/__bknd/cache",
do_endpoint: "/__bknd/do",
d1_session: {
cookie: "cf_d1_session",
header: "x-cf-d1-session",
},
};
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
env: Env;
ctx?: ExecutionContext;
request?: Request;
};
function getCookieValue(cookies: string | null, name: string) {
if (!cookies) return null;
for (const cookie of cookies.split("; ")) {
const [key, value] = cookie.split("=");
if (key === name && value) {
return decodeURIComponent(value);
}
}
return null;
}
export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
const headerKey = constants.d1_session.header;
const cookieKey = constants.d1_session.cookie;
const transport = config.d1?.transport;
return {
get: (request?: Request): D1SessionBookmark | undefined => {
if (!request || !config.d1?.session) return undefined;
if (!transport || transport === "cookie") {
const cookies = request.headers.get("Cookie");
if (cookies) {
const cookie = getCookieValue(cookies, cookieKey);
if (cookie) {
return cookie;
}
}
}
if (!transport || transport === "header") {
if (request.headers.has(headerKey)) {
return request.headers.get(headerKey) as any;
}
}
return undefined;
},
set: (c: Context, d1?: D1DatabaseSession) => {
if (!d1 || !config.d1?.session) return;
const session = d1.getBookmark();
if (session) {
if (!transport || transport === "header") {
c.header(headerKey, session);
}
if (!transport || transport === "cookie") {
setCookie(c, cookieKey, session, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 60 * 5, // 5 minutes
});
}
}
},
};
}
let media_registered: boolean = false;
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
args: Env = {} as Env,
) {
if (!media_registered && config.registerMedia !== false) {
if (typeof config.registerMedia === "function") {
config.registerMedia(args?.env as any);
} else {
registerMedia(args?.env as any);
}
if (!media_registered) {
registerMedia(args as any);
media_registered = true;
}
const appConfig = makeAdapterConfig(config, args?.env);
// if connection instance is given, don't do anything
// other than checking if D1 session is defined
if (Connection.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 sessionId = sessionHelper.get(args.request);
let session: D1DatabaseSession | undefined;
const appConfig = makeAdapterConfig(config, args);
const bindings = config.bindings?.(args);
if (!appConfig.connection) {
let db: D1Database | undefined;
// if db is given in bindings, use it
if (bindings?.db) {
$console.debug("Using database from bindings");
$console.log("Using database from bindings");
db = bindings.db;
// scan for D1Database in args
} else {
const binding = getBinding(args.env, "D1Database");
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database");
if (binding) {
$console.debug(`Using database from env "${binding.key}"`);
$console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
// if db is found, check if session is requested
if (db) {
if (config.d1?.session) {
session = db.withSession(sessionId ?? config.d1?.first);
if (!session) {
throw new Error("Couldn't create session");
}
appConfig.connection = d1Sqlite({ binding: session });
appConfig.options = {
...appConfig.options,
manager: {
...appConfig.options?.manager,
onServerInit: (server) => {
server.use(async (c, next) => {
sessionHelper.set(c, session);
await next();
});
},
},
};
} else {
appConfig.connection = d1Sqlite({ binding: db });
}
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
}
}
if (!Connection.isConnection(appConfig.connection)) {
throw new Error("Couldn't find database connection");
}
return appConfig;
}
@@ -1,75 +0,0 @@
/// <reference types="@cloudflare/workers-types" />
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
binding: DB;
};
export function d1Sqlite<DB extends D1Database | D1DatabaseSession = D1Database>(
config: D1ConnectionConfig<DB>,
) {
const db = config.binding;
return genericSqlite(
"d1-sqlite",
db,
(utils) => {
const getStmt = (sql: string, parameters?: any[] | readonly any[]) =>
db.prepare(sql).bind(...(parameters || []));
const mapResult = (res: D1Result<any>): QueryResult<any> => {
if (res.error) {
throw new Error(res.error);
}
const numAffectedRows =
res.meta.changes > 0 ? utils.parseBigInt(res.meta.changes) : undefined;
const insertId = res.meta.last_row_id
? utils.parseBigInt(res.meta.last_row_id)
: undefined;
return {
insertId,
numAffectedRows,
rows: res.results,
// @ts-ignore
meta: res.meta,
};
};
return {
db,
batch: async (stmts) => {
const res = await db.batch(
stmts.map(({ sql, parameters }) => {
return getStmt(sql, parameters);
}),
);
return res.map(mapResult);
},
query: utils.buildQueryFn({
all: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep.all()).rows;
},
run: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep.run());
},
}),
close: () => {},
};
},
{
supports: {
batching: true,
softscans: false,
},
excludeTables: ["_cf_KV", "_cf_METADATA"],
},
);
}
@@ -1,33 +0,0 @@
import { describe, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { d1Sqlite } from "./D1Connection";
describe("d1Sqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
d1Databases: ["DB"],
});
const binding = (await mf.getD1Database("DB")) as D1Database;
return {
connection: d1Sqlite({ binding }),
dispose: () => mf.dispose(),
};
},
rawDialectDetails: [
"meta.served_by",
"meta.duration",
"meta.changes",
"meta.changed_db",
"meta.size_after",
"meta.rows_read",
"meta.rows_written",
],
});
});
@@ -1,80 +0,0 @@
/// <reference types="@cloudflare/workers-types" />
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
export type D1ConnectionConfig<DB extends DurableObjecSql> =
| DurableObjectState
| {
sql: DB;
};
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) {
const db = "sql" in config ? config.sql : config.storage.sql;
return genericSqlite(
"do-sqlite",
db,
(utils) => {
// must be async to work with the miniflare mock
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
await db.exec(sql, ...(parameters || []));
const mapResult = (
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
): QueryResult<any> => {
const numAffectedRows =
cursor.rowsWritten > 0 ? utils.parseBigInt(cursor.rowsWritten) : undefined;
const insertId = undefined;
const obj = {
insertId,
numAffectedRows,
rows: cursor.toArray() || [],
// @ts-ignore
meta: {
rowsWritten: cursor.rowsWritten,
rowsRead: cursor.rowsRead,
databaseSize: db.databaseSize,
},
};
//console.info("mapResult", obj);
return obj;
};
return {
db,
batch: async (stmts) => {
// @todo: maybe wrap in a transaction?
// because d1 implicitly does a transaction on batch
return Promise.all(
stmts.map(async (stmt) => {
return mapResult(await getStmt(stmt.sql, stmt.parameters));
}),
);
},
query: utils.buildQueryFn({
all: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep).rows;
},
run: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep);
},
}),
close: () => {},
};
},
{
supports: {
batching: true,
softscans: false,
},
excludeTables: ["_cf_KV", "_cf_METADATA"],
},
);
}
@@ -1,92 +0,0 @@
/// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection";
const script = `
import { DurableObject } from "cloudflare:workers";
export class TestObject extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.storage = ctx.storage;
}
async exec(sql, ...parameters) {
//return { sql, parameters }
const cursor = this.storage.sql.exec(sql, ...parameters);
return {
rows: cursor.toArray() || [],
rowsWritten: cursor.rowsWritten,
rowsRead: cursor.rowsRead,
databaseSize: this.storage.sql.databaseSize,
}
}
async databaseSize() {
return this.storage.sql.databaseSize;
}
}
export default {
async fetch(request, env) {
const stub = env.TEST_OBJECT.get(env.TEST_OBJECT.idFromName("test"));
return stub.fetch(request);
}
}
`;
describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
const mf = new Miniflare({
modules: true,
durableObjects: { TEST_OBJECT: { className: "TestObject", useSQLite: true } },
script,
});
const ns = await mf.getDurableObjectNamespace("TEST_OBJECT");
const id = ns.idFromName("test");
const stub = ns.get(id) as unknown as DurableObjectStub<
Rpc.DurableObjectBranded & {
exec: (sql: string, ...parameters: any[]) => Promise<any>;
}
>;
const stubs: any[] = [];
const mock = {
databaseSize: 0,
exec: async function (sql: string, ...parameters: any[]) {
// @ts-ignore
const result = (await stub.exec(sql, ...parameters)) as any;
this.databaseSize = result.databaseSize;
stubs.push(result);
return {
toArray: () => result.rows,
rowsWritten: result.rowsWritten,
rowsRead: result.rowsRead,
};
},
};
return {
connection: doSqlite({ sql: mock as any }),
dispose: async () => {
await Promise.all(
stubs.map((stub) => {
try {
return stub[Symbol.dispose]();
} catch (e) {}
}),
);
await mf.dispose();
},
};
},
rawDialectDetails: ["meta.rowsWritten", "meta.rowsRead", "meta.databaseSize"],
});
});
@@ -1,45 +0,0 @@
import type { ICacheDriver } from "core/drivers";
interface WorkersKVCacheOptions {
// default time-to-live in seconds
defaultTTL?: number;
// prefix for the cache key
cachePrefix?: string;
}
export class WorkersKVCacheDriver implements ICacheDriver {
protected readonly kv: KVNamespace;
protected readonly defaultTTL?: number;
protected readonly cachePrefix: string;
constructor(kv: KVNamespace, options: WorkersKVCacheOptions = {}) {
this.kv = kv;
this.cachePrefix = options.cachePrefix ?? "";
this.defaultTTL = options.defaultTTL;
}
protected getKey(key: string): string {
return this.cachePrefix + key;
}
async get(key: string): Promise<string | undefined> {
const value = await this.kv.get(this.getKey(key));
return value === null ? undefined : value;
}
async set(key: string, value: string, ttl?: number): Promise<void> {
let expirationTtl = ttl ?? this.defaultTTL;
if (expirationTtl) {
expirationTtl = Math.max(expirationTtl, 60);
}
await this.kv.put(this.getKey(key), value, { expirationTtl: expirationTtl });
}
async del(key: string): Promise<void> {
await this.kv.delete(this.getKey(key));
}
}
export const cacheWorkersKV = (kv: KVNamespace, options?: WorkersKVCacheOptions) => {
return new WorkersKVCacheDriver(kv, options);
};
@@ -1,34 +0,0 @@
import { describe, vi, afterAll, beforeAll } from "vitest";
import { cacheWorkersKV } from "./cache";
import { viTestRunner } from "adapter/node/vitest";
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
import { Miniflare } from "miniflare";
describe("cacheWorkersKV", async () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.restoreAllMocks();
});
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
kvNamespaces: ["KV"],
});
const kv = (await mf.getKVNamespace("KV")) as unknown as KVNamespace;
cacheDriverTestSuite(viTestRunner, {
makeCache: () => cacheWorkersKV(kv),
setTime: (ms: number) => {
vi.advanceTimersByTime(ms);
},
options: {
minTTL: 60,
// doesn't work with miniflare
skipTTL: true,
},
});
});
+4 -10
View File
@@ -1,10 +1,10 @@
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
export { d1Sqlite, type D1ConnectionConfig };
export { D1Connection, type D1ConnectionConfig };
export {
getBinding,
getBindings,
@@ -12,13 +12,7 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants } from "./config";
export { StorageR2Adapter } from "./storage/StorageR2Adapter";
export { registries } from "bknd";
// for compatibility with old code
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
config: D1ConnectionConfig<DB>,
) {
return d1Sqlite<DB>(config);
export function d1(config: D1ConnectionConfig) {
return new D1Connection(config);
}
+3 -4
View File
@@ -5,9 +5,8 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Context<Env>,
{ env, ctx, ...args }: Context<Env>,
) {
const { env, ctx } = args;
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
@@ -21,7 +20,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
const app = await createRuntimeApp(
{
...makeConfig(config, args),
...makeConfig(config, env),
initialConfig,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx);
@@ -42,7 +41,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
await config.beforeBuild?.(app);
},
},
args,
{ env, ctx, ...args },
);
if (!cachedConfig) {
+2 -2
View File
@@ -3,7 +3,7 @@ import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core/utils";
import { $console } from "core";
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
@@ -64,7 +64,7 @@ export class DurableBkndApp extends DurableObject {
"type" in config.connection &&
config.connection.type === "libsql"
) {
//config.connection.config.protocol = "wss";
config.connection.config.protocol = "wss";
}
this.app = await createRuntimeApp({
+4 -4
View File
@@ -1,13 +1,13 @@
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
import { makeConfig, registerAsyncsExecutionContext } from "../config";
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
await config.onBuilt?.(app);
},
},
ctx,
ctx.env,
opts,
);
}
@@ -0,0 +1,32 @@
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();
});
@@ -1,6 +1,5 @@
import { registries } from "bknd";
import { isDebug } from "bknd/core";
// @ts-ignore
import { StringEnum } from "bknd/utils";
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { getBindings } from "../bindings";
@@ -64,49 +63,46 @@ export class StorageR2Adapter extends StorageAdapter {
async putObject(key: string, body: FileBody) {
try {
const res = await this.bucket.put(this.getKey(key), body);
const res = await this.bucket.put(key, body);
return res?.etag;
} catch (e) {
return undefined;
}
}
async listObjects(prefix = ""): Promise<{ key: string; last_modified: Date; size: number }[]> {
const list = await this.bucket.list({ limit: 50, prefix: this.getKey(prefix) });
async listObjects(
prefix?: string,
): Promise<{ key: string; last_modified: Date; size: number }[]> {
const list = await this.bucket.list({ limit: 50 });
return list.objects.map((item) => ({
key: item.key.replace(this.getKey(""), ""),
key: item.key,
size: item.size,
last_modified: item.uploaded,
}));
}
private async headObject(key: string): Promise<R2Object | null> {
return await this.bucket.head(this.getKey(key));
return await this.bucket.head(key);
}
async objectExists(key: string): Promise<boolean> {
return (await this.headObject(key)) !== null;
}
async getObject(_key: string, headers: Headers): Promise<Response> {
async getObject(key: string, headers: Headers): Promise<Response> {
let object: R2ObjectBody | null;
const key = this.getKey(_key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": guess(key),
});
const range = headers.has("range");
//console.log("getObject:headers", headersToObject(headers));
if (range) {
if (headers.has("range")) {
const options = isDebug()
? {} // miniflare doesn't support range requests
: {
range: headers,
onlyIf: headers,
};
object = (await this.bucket.get(key, options)) as R2ObjectBody;
if (!object) {
@@ -134,14 +130,13 @@ export class StorageR2Adapter extends StorageAdapter {
responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
return new Response(object.body, {
status: range ? 206 : 200,
status: object.range ? 206 : 200,
headers: responseHeaders,
});
}
private writeHttpMetadata(headers: Headers, object: R2Object | R2ObjectBody): void {
let metadata = object.httpMetadata;
if (!metadata || Object.keys(metadata).length === 0) {
// guessing is especially required for dev environment (miniflare)
metadata = {
@@ -168,17 +163,13 @@ export class StorageR2Adapter extends StorageAdapter {
}
async deleteObject(key: string): Promise<void> {
await this.bucket.delete(this.getKey(key));
await this.bucket.delete(key);
}
getObjectUrl(key: string): string {
throw new Error("Method getObjectUrl not implemented.");
}
protected getKey(key: string) {
return key;
}
toJSON(secrets?: boolean) {
return {
type: this.getName(),
@@ -1,32 +0,0 @@
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();
});
@@ -1,14 +0,0 @@
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"],
},
});
+1 -19
View File
@@ -1,11 +1,7 @@
import { App, type CreateAppConfig } from "bknd";
import { config as $config } from "bknd/core";
import { $console } from "bknd/utils";
import type { MiddlewareHandler } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController";
import { Connection } from "bknd/data";
export { Connection } from "bknd/data";
export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
@@ -63,21 +59,7 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
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;
const conf = config.connection ?? { url: ":memory:" };
connection = sqlite(conf);
$console.info(`Using ${connection.name} connection`, conf.url);
}
appConfig.connection = connection;
}
app = App.create(appConfig);
app = App.create(makeConfig(config, args));
apps.set(id, app);
}
return app;
+1
View File
@@ -3,6 +3,7 @@ import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next";
type NextjsEnv = NextApiRequest["env"];
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
cleanRequest?: { searchParams?: string[] };
};
@@ -1,57 +0,0 @@
import { genericSqlite } from "bknd/data";
import { DatabaseSync } from "node:sqlite";
export type NodeSqliteConnectionConfig = {
database: DatabaseSync;
};
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
let db: DatabaseSync;
if (config) {
if ("database" in config) {
db = config.database;
} else {
db = new DatabaseSync(config.url);
}
} else {
db = new DatabaseSync(":memory:");
}
return genericSqlite(
"node-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters = []) => getStmt(sql).all(...parameters),
run: (sql, parameters = []) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...parameters);
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.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;
},
};
},
{
supports: {
batching: false,
},
},
);
}
@@ -1,15 +0,0 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe } from "vitest";
import { viTestRunner } from "../vitest";
describe("NodeSqliteConnection", () => {
connectionTestSuite(viTestRunner, {
makeConnection: () => ({
connection: nodeSqlite({ database: new DatabaseSync(":memory:") }),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
+17 -2
View File
@@ -1,3 +1,18 @@
import { registries } from "bknd";
import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
export * from "./node.adapter";
export * from "./storage";
export * from "./connection/NodeSqliteConnection";
export { StorageLocalAdapter, type LocalAdapterConfig };
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);
};
}
@@ -1,14 +1,14 @@
import { describe, beforeAll, afterAll } from "vitest";
import { describe, before, after } from "node:test";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest";
import { nodeTestRunner } from "adapter/node/test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
before(() => disableConsoleLog());
after(enableConsoleLog);
describe("node adapter", () => {
adapterTestSuite(viTestRunner, {
adapterTestSuite(nodeTestRunner, {
makeApp: node.createApp,
makeHandler: node.createHandler,
});
+3 -3
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe } from "bun:test";
import { createApp, createHandler } from "./node.adapter";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
@@ -9,7 +9,7 @@ afterAll(enableConsoleLog);
describe("node adapter (bun)", () => {
adapterTestSuite(bunTestRunner, {
makeApp: createApp,
makeHandler: createHandler,
makeApp: node.createApp,
makeHandler: node.createHandler,
});
});
+3 -7
View File
@@ -1,11 +1,10 @@
import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/storage";
import { registerLocalMediaAdapter } from "adapter/node/index";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core";
import { $console } from "core/utils";
import type { App } from "App";
import { $console } from "core";
type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
@@ -46,11 +45,8 @@ export function createHandler<Env = NodeEnv>(
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
}
const app = await createApp(config, args ?? (process.env as Env), opts);
return app.fetch(req);
};
}
@@ -1,5 +1,5 @@
import { describe } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { describe } from "node:test";
import { nodeTestRunner } from "adapter/node/test";
import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs";
@@ -14,5 +14,5 @@ describe("StorageLocalAdapter (node)", async () => {
path: path.join(basePath, "tmp"),
});
await adapterTestSuite(viTestRunner, adapter, file);
await adapterTestSuite(nodeTestRunner, adapter, file);
});
-17
View File
@@ -1,17 +0,0 @@
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);
};
}
+1 -5
View File
@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict";
import { test, describe, beforeEach, afterEach } from "node:test";
import { test } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls
@@ -85,7 +85,6 @@ nodeTest.skipIf = (condition: boolean): Test => {
};
export const nodeTestRunner: TestRunner = {
describe,
test: nodeTest,
mock: createMockFunction,
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
@@ -97,7 +96,4 @@ export const nodeTestRunner: TestRunner = {
reject: (r) => nodeTestMatcher(r, failMsg),
}),
}),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: () => {},
};
-53
View File
@@ -1,53 +0,0 @@
import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } 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),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: afterAll,
};
-6
View File
@@ -1,6 +0,0 @@
import type { Connection } from "bknd/data";
import { bunSqlite } from "../bun/connection/BunSqliteConnection";
export function sqlite(config?: { url: string }): Connection {
return bunSqlite(config);
}
-5
View File
@@ -1,5 +0,0 @@
import { type Connection, libsql } from "bknd/data";
export function sqlite(config: { url: string }): Connection {
return libsql(config);
}
-6
View File
@@ -1,6 +0,0 @@
import type { Connection } from "bknd/data";
import { nodeSqlite } from "../node/connection/NodeSqliteConnection";
export function sqlite(config?: { url: string }): Connection {
return nodeSqlite(config);
}
+15 -11
View File
@@ -1,17 +1,24 @@
import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import {
type DevServerOptions,
default as honoViteDevServer,
} from "@hono/vite-dev-server";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
import {
type RuntimeBkndConfig,
createRuntimeApp,
type FrameworkOptions,
} from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config";
import type { MiddlewareHandler } from "hono";
export type ViteEnv = NodeJS.ProcessEnv;
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {
serveStatic?: false | MiddlewareHandler;
};
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
export function addViteScript(html: string, addBkndContext: boolean = true) {
export function addViteScript(
html: string,
addBkndContext: boolean = true,
) {
return html.replace(
"</head>",
`<script type="module">
@@ -41,10 +48,7 @@ async function createApp<ViteEnv>(
mainPath: "/src/main.tsx",
},
},
serveStatic: config.serveStatic || [
"/assets/*",
serveStatic({ root: config.distPath ?? "./" }),
],
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
},
env,
opts,
+19 -11
View File
@@ -1,21 +1,18 @@
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import type { DB } from "core";
import { $console, secureRandomString, transformObject } from "core/utils";
import { $console, type DB, type PrimaryFieldType } from "core";
import { secureRandomString, transformObject } from "core/utils";
import type { Entity, EntityManager } from "data";
import { em, entity, enumm, type FieldSchema } from "data/prototype";
import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
import { AppUserPool } from "auth/AppUserPool";
import type { AppEntity } from "core/config";
import { usersFields } from "./auth-entities";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "core" {
interface Users extends AppEntity, UserFieldSchema {}
interface DB {
users: Users;
users: { id: PrimaryFieldType } & UserFieldSchema;
}
}
@@ -126,11 +123,22 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.em.entity(entity_name) as any;
}
static usersFields = usersFields;
static usersFields = {
email: text().required(),
strategy: text({
fillable: ["create"],
hidden: ["update", "form"],
}).required(),
strategy_value: text({
fillable: ["create"],
hidden: ["read", "table", "update", "form"],
}).required(),
role: text(),
};
registerEntities() {
const users = this.getUsersEntity(true);
this.ctx.helper.ensureSchema(
this.ensureSchema(
em(
{
[users.name as "users"]: users,
@@ -143,13 +151,13 @@ export class AppAuth extends Module<typeof authConfigSchema> {
try {
const roles = Object.keys(this.config.roles ?? {});
this.ctx.helper.replaceEntityField(users, "role", enumm({ enum: roles }));
this.replaceEntityField(users, "role", enumm({ enum: roles }));
} catch (e) {}
try {
// also keep disabled strategies as a choice
const strategies = Object.keys(this.config.strategies ?? {});
this.ctx.helper.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
} catch (e) {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { AppAuth } from "auth/AppAuth";
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
import { $console } from "core/utils";
import { $console } from "core";
import { pick } from "lodash-es";
import {
InvalidConditionsException,
+4 -8
View File
@@ -4,21 +4,19 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token?: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
onTokenUpdate?: (token: string) => void | Promise<void>;
};
export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> {
return {
basepath: "/api/auth",
credentials: "include",
};
}
async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: this.options.credentials,
credentials: "include",
});
if (res.ok && res.body.token) {
@@ -29,7 +27,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: this.options.credentials,
credentials: "include",
});
if (res.ok && res.body.token) {
@@ -70,7 +68,5 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
}
async logout() {
await this.options.onTokenUpdate?.(undefined);
}
async logout() {}
}
+39 -66
View File
@@ -1,9 +1,11 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core";
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s } from "core/object/schema";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export type AuthActionResponse = {
success: boolean;
@@ -12,6 +14,10 @@ export type AuthActionResponse = {
errors?: any;
};
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
super();
@@ -50,10 +56,6 @@ export class AuthController extends Controller {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
@@ -91,16 +93,9 @@ export class AuthController extends Controller {
}
},
);
hono.get(
"create/schema.json",
describeRoute({
summary: "Get the schema for creating a user",
tags: ["auth"],
}),
async (c) => {
return c.json(create.schema);
},
);
hono.get("create/schema.json", async (c) => {
return c.json(create.schema);
});
}
mainHono.route(`/${name}/actions`, hono);
@@ -109,55 +104,42 @@ export class AuthController extends Controller {
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
hono.get(
"/me",
describeRoute({
summary: "Get the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
await this.auth.authenticator?.requestCookieRefresh(c);
return c.json({ user });
}
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
return c.json({ user: null }, 403);
},
);
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
hono.get(
"/logout",
describeRoute({
summary: "Logout the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
}
hono.get("/me", auth(), async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
}
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
return c.json({ user: null }, 403);
});
return c.redirect("/");
},
);
hono.get("/logout", auth(), async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
}
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
return c.redirect("/");
});
hono.get(
"/strategies",
describeRoute({
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
async (c) => {
const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false);
@@ -175,15 +157,6 @@ export class AuthController extends Controller {
},
);
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return hono;
return hono.all("*", (c) => c.notFound());
}
}
-14
View File
@@ -1,14 +0,0 @@
import { text } from "data/prototype";
export const usersFields = {
email: text().required(),
strategy: text({
fillable: ["create"],
hidden: ["update", "form"],
}).required(),
strategy_value: text({
fillable: ["create"],
hidden: ["read", "table", "update", "form"],
}).required(),
role: text(),
};
+5 -11
View File
@@ -1,7 +1,6 @@
import { type DB, Exception } from "core";
import { $console, type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash";
import {
$console,
type Static,
StringEnum,
type TObject,
@@ -342,24 +341,19 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
}
private deleteAuthCookie(c: Context) {
private async deleteAuthCookie(c: Context) {
$console.debug("deleting auth cookie");
deleteCookie(c, "auth", this.cookieOptions);
await deleteCookie(c, "auth", this.cookieOptions);
}
async logout(c: Context<ServerEnv>) {
$console.info("Logging out");
c.set("auth", undefined);
const cookie = await this.getAuthCookie(c);
if (cookie) {
addFlashMessage(c, "Signed out", "info");
await this.deleteAuthCookie(c);
await addFlashMessage(c, "Signed out", "info");
}
// on waku, only one cookie setting is performed
// therefore adding deleting cookie at the end
// as the flash isn't that important
this.deleteAuthCookie(c);
}
// @todo: move this to a server helper
@@ -1,6 +1,6 @@
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
import { tbValidator as tb } from "core";
import { $console, hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
import { $console, tbValidator as tb } from "core";
import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
import { Hono } from "hono";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
import * as tbbox from "@sinclair/typebox";
+2 -2
View File
@@ -1,5 +1,5 @@
import { Exception, Permission } from "core";
import { $console, objectTransform } from "core/utils";
import { $console, Exception, Permission } from "core";
import { objectTransform } from "core/utils";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller";
import { Role } from "./Role";
+7 -3
View File
@@ -1,5 +1,5 @@
import type { Permission } from "core";
import { $console, patternMatch } from "core/utils";
import { $console, type Permission } from "core";
import { patternMatch } from "core/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Controller";
@@ -60,7 +60,11 @@ export const auth = (options?: {
}
await next();
// @todo: potentially add cookie refresh if content-type html and about to expire
if (!skipped) {
// renew cookie if applicable
authenticator?.requestCookieRefresh(c);
}
// release
authCtx.skip = false;
@@ -29,8 +29,30 @@ export const cloudflare = {
{ dir: ctx.dir },
);
const db = ctx.skip
? "d1"
: await $p.select({
message: "What database do you want to use?",
options: [
{ label: "Cloudflare D1", value: "d1" },
{ label: "LibSQL", value: "libsql" },
],
});
if ($p.isCancel(db)) {
process.exit(1);
}
try {
await createD1(ctx);
switch (db) {
case "d1":
await createD1(ctx);
break;
case "libsql":
await createLibsql(ctx);
break;
default:
throw new Error("Invalid database");
}
} catch (e) {
const message = (e as any).message || "An error occurred";
$p.log.warn(
@@ -38,14 +60,7 @@ export const cloudflare = {
);
}
try {
await createR2(ctx);
} catch (e) {
const message = (e as any).message || "An error occurred";
$p.log.warn(
"Couldn't add R2 bucket. You can add it manually later. Error: " + c.red(message),
);
}
await createR2(ctx);
},
} as const satisfies Template;
@@ -74,21 +89,6 @@ async function createD1(ctx: TemplateSetupCtx) {
})(),
);
await overrideJson(
WRANGLER_FILE,
(json) => ({
...json,
d1_databases: [
{
binding: "DB",
database_name: name,
database_id: "00000000-0000-0000-0000-000000000000",
},
],
}),
{ dir: ctx.dir },
);
if (!ctx.skip) {
exec(`npx wrangler d1 create ${name}`);
@@ -98,6 +98,62 @@ async function createD1(ctx: TemplateSetupCtx) {
})(),
);
}
await overrideJson(
WRANGLER_FILE,
(json) => ({
...json,
d1_databases: [
{
binding: "DB",
database_name: name,
database_id: uuid(),
},
],
}),
{ dir: ctx.dir },
);
}
async function createLibsql(ctx: TemplateSetupCtx) {
await overrideJson(
WRANGLER_FILE,
(json) => ({
...json,
vars: {
DB_URL: "http://127.0.0.1:8080",
},
}),
{ dir: ctx.dir },
);
await overridePackageJson(
(pkg) => ({
...pkg,
scripts: {
...pkg.scripts,
db: "turso dev",
dev: "npm run db && wrangler dev",
},
}),
{ dir: ctx.dir },
);
await $p.stream.info(
(async function* () {
yield* typewriter("Database set to LibSQL");
await wait();
yield* typewriter(
`\nYou can now run ${c.cyan("npm run db")} to start the database and ${c.cyan("npm run dev")} to start the worker.`,
c.dim,
);
await wait();
yield* typewriter(
`\nAlso make sure you have Turso's CLI installed. Check their docs on how to install at ${c.cyan("https://docs.turso.tech/cli/introduction")}`,
c.dim,
);
})(),
);
}
async function createR2(ctx: TemplateSetupCtx) {
@@ -141,11 +197,9 @@ async function createR2(ctx: TemplateSetupCtx) {
process.exit(1);
}
await $p.stream.info(
(async function* () {
yield* typewriter("Now running wrangler to create a R2 bucket...");
})(),
);
if (!ctx.skip) {
exec(`npx wrangler r2 bucket create ${name}`);
}
await overrideJson(
WRANGLER_FILE,
@@ -160,8 +214,4 @@ async function createR2(ctx: TemplateSetupCtx) {
}),
{ dir: ctx.dir },
);
if (!ctx.skip) {
exec(`npx wrangler r2 bucket create ${name}`);
}
}
-1
View File
@@ -5,4 +5,3 @@ export { debug } from "./debug";
export { user } from "./user";
export { create } from "./create";
export { copyAssets } from "./copy-assets";
export { types } from "./types";
+6 -1
View File
@@ -1,5 +1,6 @@
import path from "node:path";
import { $console } from "core/utils";
import type { Config } from "@libsql/client/node";
import { $console, config } from "core";
import type { MiddlewareHandler } from "hono";
import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys";
@@ -26,6 +27,10 @@ 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(
server: Platform,
app: App,
+32 -15
View File
@@ -1,9 +1,9 @@
import type { Config } from "@libsql/client/node";
import type { App, CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node/storage";
import { App, type CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node";
import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander";
import { config } from "core";
import { colorizeConsole, config } from "core";
import dotenv from "dotenv";
import { registries } from "modules/registries";
import c from "picocolors";
@@ -11,19 +11,19 @@ import path from "node:path";
import {
PLATFORMS,
type Platform,
attachServeStatic,
getConfigPath,
getConnectionCredentialsFromEnv,
serveStatic,
startServer,
} from "./platform";
import { createRuntimeApp, makeConfig } from "adapter";
import { colorizeConsole, isBun } from "core/utils";
import { makeConfig } from "adapter";
import { isBun as $isBun } from "cli/utils/sys";
const env_files = [".env", ".dev.vars"];
dotenv.config({
path: env_files.map((file) => path.resolve(process.cwd(), file)),
});
const is_bun = isBun();
const isBun = $isBun();
export const run: CliCommand = (program) => {
program
@@ -44,14 +44,15 @@ export const run: CliCommand = (program) => {
)
.addOption(new Option("-c, --config <config>", "config file"))
.addOption(
new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
"config",
),
)
.addOption(new Option("--db-token <db>", "database token").conflicts("config"))
.addOption(
new Option("--server <server>", "server type")
.choices(PLATFORMS)
.default(is_bun ? "bun" : "node"),
.default(isBun ? "bun" : "node"),
)
.addOption(new Option("--no-open", "don't open browser window on start"))
.action(action);
@@ -71,10 +72,23 @@ type MakeAppConfig = {
};
async function makeApp(config: MakeAppConfig) {
return await createRuntimeApp({
serveStatic: await serveStatic(config.server?.platform ?? "node"),
...config,
});
const app = App.create({ connection: config.connection });
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) {
@@ -90,6 +104,7 @@ type RunOptions = {
memory?: boolean;
config?: string;
dbUrl?: string;
dbToken?: string;
server: Platform;
open?: boolean;
};
@@ -100,8 +115,10 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
let app: App | undefined = undefined;
// first start from arguments if given
if (options.dbUrl) {
console.info("Using connection from", c.cyan("--db-url"), c.cyan(options.dbUrl));
const connection = options.dbUrl ? { url: options.dbUrl } : undefined;
console.info("Using connection from", c.cyan("--db-url"));
const connection = options.dbUrl
? { url: options.dbUrl, authToken: options.dbToken }
: undefined;
app = await makeApp({ connection, server: { platform: options.server } });
// check configuration file to be present
-1
View File
@@ -1 +0,0 @@
export * from "./types";
-37
View File
@@ -1,37 +0,0 @@
import type { CliCommand } from "cli/types";
import { Option } from "commander";
import { makeAppFromEnv } from "cli/commands/run";
import { EntityTypescript } from "data/entities/EntityTypescript";
import { writeFile } from "cli/utils/sys";
import c from "picocolors";
export const types: CliCommand = (program) => {
program
.command("types")
.description("generate types")
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
.addOption(new Option("--no-write", "do not write to file").default(true))
.action(action);
};
async function action({
outfile,
write,
}: {
outfile: string;
write: boolean;
}) {
const app = await makeAppFromEnv({
server: "node",
});
await app.build();
const et = new EntityTypescript(app.em);
if (write) {
await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`);
} else {
console.info(et.toString());
}
}
+2 -2
View File
@@ -9,9 +9,9 @@ import type { PasswordStrategy } from "auth/authenticate/strategies";
import { makeAppFromEnv } from "cli/commands/run";
import type { CliCommand } from "cli/types";
import { Argument } from "commander";
import { $console } from "core/utils";
import { $console } from "core";
import c from "picocolors";
import { isBun } from "core/utils";
import { isBun } from "cli/utils/sys";
export const user: CliCommand = (program) => {
program
+9 -12
View File
@@ -1,9 +1,16 @@
import { $console } from "core/utils";
import { execSync, exec as nodeExec } from "node:child_process";
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import path from "node:path";
import url from "node:url";
export function isBun(): boolean {
try {
return typeof Bun !== "undefined";
} catch (e) {
return false;
}
}
export function getRootPath() {
const _path = path.dirname(url.fileURLToPath(import.meta.url));
// because of "src", local needs one more level up
@@ -41,16 +48,6 @@ export async function fileExists(filePath: string) {
}
}
export async function writeFile(filePath: string, content: string) {
try {
await nodeWriteFile(path.resolve(process.cwd(), filePath), content);
return true;
} catch (e) {
$console.error("Failed to write file", e);
return false;
}
}
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
const stdio = opts?.silent ? "pipe" : "inherit";
const output = execSync(command, {
+1 -2
View File
@@ -1,7 +1,6 @@
import { PostHog } from "posthog-js-lite";
import { getVersion } from "cli/utils/sys";
import { env, isDebug } from "core";
import { $console } from "core/utils";
import { $console, env, isDebug } from "core";
type Properties = { [p: string]: any };
+1 -5
View File
@@ -3,11 +3,7 @@
*/
import type { Generated } from "kysely";
export type PrimaryFieldType<IdType = number | string> = IdType | Generated<IdType>;
export interface AppEntity<IdType = number | string> {
id: PrimaryFieldType<IdType>;
}
export type PrimaryFieldType = number | Generated<number>;
export interface DB {
// make sure to make unknown as "any"
@@ -23,12 +23,6 @@ function hasColors() {
}
const __consoles = {
critical: {
prefix: "CRT",
color: colors.red,
args_color: colors.red,
original: console.error,
},
error: {
prefix: "ERR",
color: colors.red,
-72
View File
@@ -1,72 +0,0 @@
import type { TestRunner } from "core/test";
import type { ICacheDriver } from "./index";
export function cacheDriverTestSuite(
testRunner: TestRunner,
{
makeCache,
setTime,
options,
}: {
makeCache: () => ICacheDriver;
setTime: (ms: number) => void;
options?: {
minTTL?: number;
skipTTL?: boolean;
};
},
) {
const { test, expect } = testRunner;
const minTTL = options?.minTTL ?? 1;
test("get within ttl", async () => {
const cache = makeCache();
await cache.set("ttl", "bar", minTTL + 2); // 2 second TTL
setTime(minTTL * 1000 + 1000); // advance by 1 second
expect(await cache.get("ttl")).toBe("bar");
});
test("set and get returns value", async () => {
const cache = makeCache();
await cache.set("value", "bar");
expect(await cache.get("value")).toBe("bar");
});
test("get returns undefined for missing key", async () => {
const cache = makeCache();
expect(await cache.get("missing" + Math.random())).toBeUndefined();
});
test("delete removes value", async () => {
const cache = makeCache();
await cache.set("delete", "bar");
await cache.del("delete");
expect(await cache.get("delete")).toBeUndefined();
});
test("set overwrites value", async () => {
const cache = makeCache();
await cache.set("overwrite", "bar");
await cache.set("overwrite", "baz");
expect(await cache.get("overwrite")).toBe("baz");
});
test("set with ttl expires", async () => {
const cache = makeCache();
await cache.set("expire", "bar", minTTL + 1); // 1 second TTL
expect(await cache.get("expire")).toBe("bar");
// advance time
setTime(minTTL * 1000 * 2000);
if (options?.skipTTL) {
await cache.del("expire");
}
expect(await cache.get("expire")).toBeUndefined();
});
test("set without ttl does not expire", async () => {
const cache = makeCache();
await cache.set("ttl0", "bar");
expect(await cache.get("ttl0")).toBe("bar");
setTime(1000);
expect(await cache.get("ttl0")).toBe("bar");
});
}
-52
View File
@@ -1,52 +0,0 @@
import { cacheDriverTestSuite } from "./cache-driver-test-suite";
import { memoryCache } from "./in-memory";
import { bunTestRunner } from "adapter/bun/test";
import { setSystemTime, afterAll, beforeAll, test, expect, describe } from "bun:test";
let baseTime = Date.now();
beforeAll(() => {
baseTime = Date.now();
setSystemTime(new Date(baseTime));
});
afterAll(() => {
setSystemTime(); // Reset to real time
});
describe("InMemoryCacheDriver", () => {
cacheDriverTestSuite(bunTestRunner, {
makeCache: () => memoryCache(),
setTime: (ms: number) => {
setSystemTime(new Date(baseTime + ms));
},
});
test("evicts least recently used entries by byte size", async () => {
// maxSize = 20 bytes for this test
const cache = memoryCache({ maxSize: 20 });
// each key and value is 1 char = 1 byte (ASCII)
// totals to 2 bytes each
await cache.set("a", "1");
await cache.set("b", "2");
await cache.set("c", "3");
await cache.set("d", "4");
await cache.set("e", "5");
// total: 10 bytes
// now add a large value to force eviction
await cache.set("big", "1234567890");
// should evict least recently used entries until it fits
// only "big" and possibly one other small entry should remain
expect(await cache.get("big")).toBe("1234567890");
// the oldest keys should be evicted
expect(await cache.get("a")).toBeUndefined();
expect(await cache.get("b")).toBeUndefined();
// the most recent small keys may or may not remain depending on eviction order
});
test("throws if entry is too large to ever fit", async () => {
const cache = memoryCache({ maxSize: 5 });
// key: 3, value: 10 = 13 bytes
expect(cache.set("big", "1234567890")).rejects.toThrow();
});
});
-123
View File
@@ -1,123 +0,0 @@
import type { ICacheDriver } from "./index";
interface InMemoryCacheOptions {
// maximum total size in bytes for all keys and values
maxSize?: number;
// default time-to-live in seconds
defaultTTL?: number;
}
interface CacheEntry {
value: string;
// timestamp in ms, or null for no expiry
expiresAt: number | null;
// size in bytes of this entry (key + value)
size: number;
}
function byteLength(str: string): number {
return new TextEncoder().encode(str).length;
}
export class InMemoryCacheDriver implements ICacheDriver {
protected cache: Map<string, CacheEntry>;
protected maxSize: number;
protected defaultTTL: number;
protected currentSize: number;
constructor(options: InMemoryCacheOptions = {}) {
this.maxSize = options.maxSize ?? 1024 * 1024 * 10; // 10MB default
this.defaultTTL = options.defaultTTL ?? 60 * 60; // 1 hour default
this.cache = new Map();
this.currentSize = 0;
}
protected now(): number {
return Date.now();
}
protected isExpired(entry: CacheEntry): boolean {
return entry.expiresAt !== null && entry.expiresAt <= this.now();
}
protected setEntry(key: string, entry: CacheEntry) {
const oldEntry = this.cache.get(key);
const oldSize = oldEntry ? oldEntry.size : 0;
let projectedSize = this.currentSize - oldSize + entry.size;
// if the entry itself is too large, throw
if (entry.size > this.maxSize) {
throw new Error(
`InMemoryCacheDriver: entry too large (entry: ${entry.size}, max: ${this.maxSize})`,
);
}
// evict LRU until it fits
while (projectedSize > this.maxSize && this.cache.size > 0) {
// remove least recently used (first inserted)
const lruKey = this.cache.keys().next().value;
if (typeof lruKey === "string") {
const lruEntry = this.cache.get(lruKey);
if (lruEntry) {
this.currentSize -= lruEntry.size;
}
this.cache.delete(lruKey);
projectedSize = this.currentSize - oldSize + entry.size;
} else {
break;
}
}
if (projectedSize > this.maxSize) {
throw new Error(
`InMemoryCacheDriver: maxSize exceeded after eviction (attempted: ${projectedSize}, max: ${this.maxSize})`,
);
}
if (oldEntry) {
this.currentSize -= oldSize;
}
this.cache.delete(key); // Remove to update order (for LRU)
this.cache.set(key, entry);
this.currentSize += entry.size;
}
async get(key: string): Promise<string | undefined> {
const entry = this.cache.get(key);
if (!entry) return;
if (this.isExpired(entry)) {
this.cache.delete(key);
this.currentSize -= entry.size;
return;
}
// mark as recently used
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
async set(key: string, value: string, ttl?: number): Promise<void> {
const expiresAt =
ttl === undefined
? this.defaultTTL > 0
? this.now() + this.defaultTTL * 1000
: null
: ttl > 0
? this.now() + ttl * 1000
: null;
const size = byteLength(key) + byteLength(value);
this.setEntry(key, { value, expiresAt, size });
}
async del(key: string): Promise<void> {
const entry = this.cache.get(key);
if (entry) {
this.currentSize -= entry.size;
this.cache.delete(key);
}
}
}
export const memoryCache = (options?: InMemoryCacheOptions) => {
return new InMemoryCacheDriver(options);
};
-32
View File
@@ -1,32 +0,0 @@
/**
* Interface for cache driver implementations
* Defines standard methods for interacting with a cache storage system
*/
export interface ICacheDriver {
/**
* Retrieves a value from the cache by its key
*
* @param key unique identifier for the cached value
* @returns resolves to the cached string value or undefined if not found
*/
get(key: string): Promise<string | undefined>;
/**
* Stores a value in the cache with an optional time-to-live
*
* @param key unique identifier for storing the value
* @param value string value to cache
* @param ttl optional time-to-live in seconds before the value expires
* @throws if the value cannot be stored
*/
set(key: string, value: string, ttl?: number): Promise<void>;
/**
* Removes a value from the cache
*
* @param key unique identifier of the value to delete
*/
del(key: string): Promise<void>;
}
export { cacheDriverTestSuite } from "./cache-driver-test-suite";
-28
View File
@@ -1,28 +0,0 @@
export interface IEmailDriver<Data = unknown, Options = object> {
send(
to: string,
subject: string,
body: string | { text: string; html: string },
options?: Options,
): Promise<Data>;
}
import type { BkndConfig } from "bknd";
import { resendEmail, memoryCache } from "bknd/core";
export default {
onBuilt: async (app) => {
app.server.get("/send-email", async (c) => {
if (await app.drivers?.email?.send("test@test.com", "Test", "Test")) {
return c.text("success");
}
return c.text("failed");
});
},
options: {
drivers: {
email: resendEmail({ apiKey: "..." }),
cache: memoryCache(),
},
},
} as const satisfies BkndConfig;
@@ -1,20 +0,0 @@
import { describe, it, expect } from "bun:test";
import { mailchannelsEmail } from "./mailchannels";
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("mailchannels", () => {
it("should throw on failed", async () => {
const driver = mailchannelsEmail({ apiKey: "invalid" } as any);
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
});
it("should send an email", async () => {
const driver = mailchannelsEmail({
apiKey: process.env.MAILCHANNELS_API_KEY!,
from: { email: "accounts@bknd.io", name: "Dennis Senn" },
});
const response = await driver.send("ds@bknd.io", "Test", "Test");
expect(response).toBeDefined();
});
});

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