Compare commits

...

61 Commits

Author SHA1 Message Date
dswbx 06d7558c3c feat: batch schema manager statements
run all schema modification queries in a single batch/transaction, to enable automatic rollbacks, and to stay within cloudflare's subrequest limits in free plan.
2025-09-24 14:48:45 +02:00
dswbx 832eb6ac31 feat: add migration to version 10 and update tests
introduced a new config migration to version 10, updated related tests to validate migration.
2025-09-24 09:58:22 +02:00
dswbx aa8bf156b0 fix: throw error if fetch called before build
added a check in the fetch getter to prevent its usage before the application is built, ensuring proper usage and avoiding runtime issues.
2025-09-24 09:53:28 +02:00
dswbx 83c1c86eff chore: update Bun version to 1.2.22 and bump package version to 0.18.0-rc.7 2025-09-23 14:00:08 +02:00
dswbx ffe53d3fb5 fix: ensure form updates with latest data after mutation
added a form reset to reflect up-to-date data changes and adjusted mutation to prevent id exclusion, ensuring lists are updated properly.
2025-09-23 13:47:10 +02:00
dswbx 49aee37199 feat: lazy load mcp server 2025-09-23 13:46:39 +02:00
dswbx 54eee8cd34 feat: add schema marking and validation skip mechanism 2025-09-23 13:43:37 +02:00
dswbx 5e62e681e7 feat: introduce DummyConnection class for testing purposes 2025-09-21 14:20:07 +02:00
dswbx 99c1645411 chore: bump version to 0.18.0-rc.6 and fix EntityTypescript for system entities 2025-09-21 14:16:57 +02:00
dswbx 564eab23af docs: improve CLI documentation formatting 2025-09-20 20:02:56 +02:00
dswbx f2da54c92b docs: enhance documentation with new modes and plugins
- Updated documentation to include new modes for configuring bknd (UI-only, Code-only, Hybrid).
- Introduced `syncSecrets` plugin example in the extending plugins documentation.
- Added `react-icons` dependency to package.json and package-lock.json.
- Enhanced various documentation pages with icons and improved content structure.
2025-09-20 19:57:38 +02:00
dswbx cd262097dc chore: bump version to 0.18.0-rc.5 2025-09-20 14:58:11 +02:00
dswbx d1726b23f1 fix: prevent rendering of hidden fields in EntityForm component 2025-09-20 14:51:30 +02:00
dswbx 50e9be833b chore: updated cf deps, improved vite dev 2025-09-20 13:59:41 +02:00
dswbx 806b7427c1 Merge pull request #266 from bknd-io/feat/generic-sqlite-modes
feat: enhance SQLite connection configurations to allow WAL
2025-09-20 10:25:19 +02:00
dswbx 8742938074 docs: swap Bun and Node.js SQLite sections 2025-09-20 10:21:08 +02:00
dswbx 36e61cab3f feat: enhance SQLite connection configurations to allow WAL
Updated the Bun and Node SQLite connection implementations to support additional configuration options, including `onCreateConnection`. Introduced tests for connection creation to validate database instance types and ensure proper callback execution. Improved type exports for better integration with existing code.
2025-09-20 10:16:33 +02:00
dswbx 17d4adbbfa chore: bump version to 0.18.0-rc.4 and enhance test logging
Updated the package version to 0.18.0-rc.4. Improved test logging by disabling console output during tests to reduce noise and enhance readability. Adjusted various test files to implement console log management, ensuring cleaner test outputs.
2025-09-19 20:41:35 +02:00
dswbx d052871fe0 fix: update external dependencies and enhance error logging
Added "wrangler" to the external dependencies in the build configuration. Improved error logging in the Storage class to include the file name when image dimension retrieval fails. Enhanced the InvalidSchemaError to provide additional context by including the schema in the error message.
2025-09-19 20:05:53 +02:00
dswbx b0e5a49b0b Merge pull request #265 from bknd-io/feat/admin-ui-customizations
feat: add admin options for entities and app shell
2025-09-19 20:04:07 +02:00
dswbx 35cbcc221a added missing types export for options, init admin ui docs as patch 2025-09-19 20:01:03 +02:00
dswbx 26d1f2b583 feat: add admin options for entities and app shell
Introduced `BkndAdminEntitiesOptions` and `BkndAdminAppShellOptions` for advanced customization of entity actions, headers, footers, and app shell user menu. Updated related components, hooks, and types for seamless integration with the new configuration options.
2025-09-19 11:36:31 +02:00
dswbx 95d114ea68 test: adjust expectation for beforeBuild call count
Updated the test to expect `beforeBuild` to be called twice, reflecting changes in the test conditions or setup logic.
2025-09-19 11:30:38 +02:00
dswbx 059becbf09 refactor: update id handling for media entity
Revised `id` and `entity_id` types to support both string and number for flexibility. Adjusted `PolymorphicRelation` to use `PrimaryFieldType` for improved type safety and maintainability.
2025-09-19 11:28:51 +02:00
dswbx 91120091a3 feat: add fallback redirect for cloudflare image optimization plugin
Introduced `fallbackRedirect` option to enable a redirect to the original image if image optimization fails. Added logging for platform proxy usage and adjusted `beforeBuild` to handle undefined `app`. Minor improvements and process termination for clear command flow.
2025-09-19 11:28:04 +02:00
dswbx 99a812cc75 test: remove unused parameter from getDummyConnection calls 2025-09-18 10:23:18 +02:00
dswbx 29a2de31c6 bump version to 0.18.0-rc.3 2025-09-18 10:06:56 +02:00
dswbx 2688bf261b Merge pull request #264 from bknd-io/fix/admin-ui-form-improvements
ui: improve form field components, add support for custom fields, export components
2025-09-18 10:04:19 +02:00
dswbx 15c32c0e6d ui: improve form field components, add support for custom fields, export components
Enhanced `FieldLabel` to support tooltips and additional props. Refactored form field components for better customizability and ensured compatibility with new `CustomField`. Updated `onChange` handlers to include context for improved flexibility. Added exports for UI components.
2025-09-18 10:03:16 +02:00
dswbx d6f2500be2 Merge pull request #263 from bknd-io/fix/cf-vite-writer-plugin-large-payloads
fix: implement chunked request handling for cloudflare vite dev-fs plugin
2025-09-18 10:00:19 +02:00
dswbx fa8931ad15 fix: implement chunked request handling for cloudflare vite dev-fs plugin
Reworked the dev-fs plugin to handle chunked write requests efficiently. Added mechanisms to track, process, and validate chunks, ensuring reliable file writes even for large data. Enhanced verbose logging for better debugging.
2025-09-18 09:59:10 +02:00
dswbx 6e3060141b refactor: improve type handling and config structure
Updated various type definitions to improve flexibility and maintain consistency, including `MaybePromise` and `PartialRec`. Adjusted `App` class and related configurations to properly utilize these changes.
2025-09-18 09:58:12 +02:00
dswbx 07029e3797 Merge pull request #262 from bknd-io/fix/typegen-valid-identifiers-and-comments
fix: typegen: adjust field formatting and type name handling
2025-09-18 09:57:01 +02:00
dswbx 3672cc7f2c fix: adjust field formatting and type name handling
Enabled multiline comments for fields to align with entity settings. Updated `typeName` to remove spaces for consistent formatting.
2025-09-18 09:55:33 +02:00
dswbx ac32eb128e Merge pull request #261 from bknd-io/fix/unique-indexes-without-required-first
refactor: remove unique field validation from `EntityIndex`
2025-09-18 09:53:42 +02:00
dswbx ffdf453fea fix: remove non-unique index test
The removed test no longer aligns with the current `EntityIndex` implementation, as non-unique validation is not applicable anymore.
2025-09-18 09:52:05 +02:00
dswbx 9dd7432e6b refactor: remove unique field validation from EntityIndex
Eliminated validation requiring the first field in unique indices to be mandatory. This simplifies the logic as the requirement is no longer necessary.
2025-09-18 09:50:05 +02:00
dswbx 8c4a8d91a2 fix: secret handling and relation naming logic
Implemented improved handling for secrets in `DbModuleManager`, allowing secret merging and building before saving. Enhanced `ManyToManyRelation` naming to produce valid identifiers and ensured better testing coverage for both changes.
2025-09-18 09:47:47 +02:00
dswbx 9aae6e78d6 config: allow full property usage in app function and improve type consistency
Added support for all properties in the `app` function configuration and ensured consistent type definitions for `BkndConfig`. Updated `makeConfig` function to reflect these changes and added relevant unit tests.
2025-09-16 16:41:16 +02:00
dswbx 317b2b50ad fix cli for cloudflare proxy and plugins
- proper cli exists required for cloudflare proxy to dispose
- updated cloudflare proxy to allow proxy options (e.g. remote)
- updated config command to include proper required structure for the export on code mode
2025-09-16 16:08:01 +02:00
dswbx a0b2dde034 fix: improve asset handling, logging, and ui conditions
Refactored asset handling to support multiple CSS entries and fixed context handling in authenticator when executed through mcp. Adjusted UI readonly conditions.
2025-09-16 16:04:09 +02:00
dswbx 70eaa22327 Merge remote-tracking branch 'origin/main' into release/0.18
# Conflicts:
#	app/package.json
2025-09-15 16:29:15 +02:00
dswbx ddfc3e599f docs: corrected app function parameter signature 2025-09-15 14:36:00 +02:00
dswbx c11dd2bd9e Merge pull request #260 from bknd-io/fix/docs-env
update docs: add `bknd.config.ts` example and clarify cloudflare setup
2025-09-15 12:46:40 +02:00
dswbx 77c85bfd5c update docs: add bknd.config.ts example and clarify cloudflare setup
Added a detailed example to `bknd.config.ts` documentation showcasing database connection and custom routes. Updated Cloudflare guide with clearer sections, marking modern assets usage as recommended and workers sites as legacy.
2025-09-15 12:41:49 +02:00
dswbx 732cd31e1f ui: fix appshell section tabs styling 2025-09-05 17:24:47 +02:00
dswbx 1a7670f57a Merge pull request #253 from bknd-io/feat/ui-readonly-and-fixes
ui: reflect readonly mode by hiding controls + various small styling fixes
2025-09-05 17:10:55 +02:00
dswbx af573cc79a ui: reflect readonly mode by hiding controls + various small styling fixes 2025-09-05 17:09:50 +02:00
dswbx 1df87c8a16 bump rc version 2025-09-05 15:47:54 +02:00
dswbx 3c5e3f9638 manager: fix secrets extraction for both modes 2025-09-05 14:14:12 +02:00
dswbx 7a0a7481c0 Merge pull request #252 from bknd-io/feat/manager-secrets-and-plugins
improved module manager's secrets extraction, updated plugins
2025-09-05 13:32:36 +02:00
dswbx bdcc81b2f1 improved module manager's secrets extraction, updated plugins 2025-09-05 13:31:20 +02:00
dswbx 94e168589d Merge pull request #249 from bknd-io/feat/code-first-and-secrets-extract
init code-first mode by splitting module manager
2025-09-04 20:49:45 +02:00
dswbx c22339e4bf Merge pull request #247 from bknd-io/fix/cli-externalize-deps
cli: externalize all direct dependencies to prevent being bundled
2025-09-04 20:43:32 +02:00
dswbx d01058595f update system info result, fix cloudflare export 2025-09-04 20:42:19 +02:00
dswbx fdec5f0693 adapters: remove runtime options, serve app always fresh to prevent race conditions 2025-09-04 19:36:21 +02:00
dswbx e8f2c70279 mm: added secrets extraction for db mode 2025-09-04 15:20:12 +02:00
dswbx 2c5371610b fix types, refactor db manager to db 2025-09-04 13:00:27 +02:00
dswbx 758a89b5d7 fix tests: replace initialConfig with config 2025-09-04 10:44:14 +02:00
dswbx e3888537f9 init code-first mode by splitting module manager 2025-09-04 09:21:35 +02:00
dswbx a559a2eabc cli: externalize all direct dependencies to prevent being bundled 2025-09-03 17:17:07 +02:00
189 changed files with 4608 additions and 1668 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: "1.2.19" bun-version: "1.2.22"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
+6 -3
View File
@@ -1,12 +1,15 @@
import { afterEach, describe, test, expect } from "bun:test"; import { afterEach, describe, test, expect, beforeAll, afterAll } from "bun:test";
import { App, createApp } from "core/test/utils"; import { App, createApp } from "core/test/utils";
import { getDummyConnection } from "./helper"; import { getDummyConnection } from "./helper";
import { Hono } from "hono"; import { Hono } from "hono";
import * as proto from "../src/data/prototype"; import * as proto from "../src/data/prototype";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup); afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("App tests", async () => { describe("App tests", async () => {
test("boots and pongs", async () => { test("boots and pongs", async () => {
@@ -19,7 +22,7 @@ describe("App tests", async () => {
test("plugins", async () => { test("plugins", async () => {
const called: string[] = []; const called: string[] = [];
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
}, },
+23 -34
View File
@@ -1,4 +1,4 @@
import { expect, describe, it, beforeAll, afterAll } from "bun:test"; import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter"; import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
@@ -19,50 +19,39 @@ describe("adapter", () => {
expect( expect(
omitKeys( omitKeys(
await adapter.makeConfig( await adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) }, { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } }, { env: { TEST: "test" } },
), ),
["connection"], ["connection"],
), ),
).toEqual({ ).toEqual({
initialConfig: { server: { cors: { origin: "test" } } }, config: { server: { cors: { origin: "test" } } },
}); });
}); });
/* it.only("...", async () => { it("allows all properties in app function", async () => {
const app = await adapter.createAdapterApp(); const called = mock(() => null);
}); */ const config = await adapter.makeConfig(
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
{ {
initialConfig: { server: { cors: { origin: "random" } } }, app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: {
mode: "db",
},
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
}, },
undefined, { foo: "bar" },
{ id },
); );
const second = await adapter.createAdapterApp(); expect(config.connection).toEqual({ url: "test" });
const third = await adapter.createAdapterApp(undefined, undefined, { id }); expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(config.options).toEqual({ mode: "db" });
await first.build(); await config.onBuilt?.(null as any);
await second.build(); expect(called).toHaveBeenCalled();
await third.build();
expect(first.toJSON().server.cors.origin).toEqual("random");
expect(first).toBe(third);
expect(first).not.toBe(second);
expect(second).not.toBe(third);
expect(second.toJSON().server.cors.origin).toEqual("*");
// recreate the first one
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
await first2.build();
expect(first2).not.toBe(first);
expect(first2).not.toBe(third);
expect(first2).not.toBe(second);
expect(first2.toJSON().server.cors.origin).toEqual("*");
}); });
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
-1
View File
@@ -42,7 +42,6 @@ describe("Api", async () => {
expect(api.isAuthVerified()).toBe(false); expect(api.isAuthVerified()).toBe(false);
const params = api.getParams(); const params = api.getParams();
console.log(params);
expect(params.token).toBe(token); expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie"); expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com"); expect(params.host).toBe("http://example.com");
+18 -4
View File
@@ -1,9 +1,23 @@
import { describe, expect, mock, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src"; import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils"; import { App, createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype"; import * as proto from "data/prototype";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("App", () => { describe("App", () => {
test("use db mode by default", async () => {
const app = createApp();
await app.build();
expect(app.mode).toBe("db");
expect(app.isReadOnly()).toBe(false);
expect(app.modules instanceof DbModuleManager).toBe(true);
});
test("seed includes ctx and app", async () => { test("seed includes ctx and app", async () => {
const called = mock(() => null); const called = mock(() => null);
await createApp({ await createApp({
@@ -29,7 +43,7 @@ describe("App", () => {
expect(called).toHaveBeenCalled(); expect(called).toHaveBeenCalled();
const app = createApp({ const app = createApp({
initialConfig: { config: {
data: proto data: proto
.em({ .em({
todos: proto.entity("todos", { todos: proto.entity("todos", {
@@ -139,7 +153,7 @@ describe("App", () => {
test("getMcpClient", async () => { test("getMcpClient", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
+2
View File
@@ -16,6 +16,7 @@ describe("AppServer", () => {
mcp: { mcp: {
enabled: false, enabled: false,
path: "/api/system/mcp", path: "/api/system/mcp",
logLevel: "warning",
}, },
}); });
} }
@@ -38,6 +39,7 @@ describe("AppServer", () => {
mcp: { mcp: {
enabled: false, enabled: false,
path: "/api/system/mcp", path: "/api/system/mcp",
logLevel: "warning",
}, },
}); });
} }
+2 -1
View File
@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
let server: McpServer; let server: McpServer;
beforeEach(async () => { beforeEach(async () => {
app = createApp({ app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
jwt: { jwt: {
@@ -44,6 +44,7 @@ describe("mcp auth", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {
+11 -2
View File
@@ -1,14 +1,18 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { registries } from "index"; import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("mcp", () => { describe("mcp", () => {
it("should have tools", async () => { it("should have tools", async () => {
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
}, },
@@ -30,6 +34,11 @@ describe("mcp", () => {
}); });
await app.build(); await app.build();
// expect mcp to not be loaded yet
expect(app.mcp).toBeNull();
// after first request, mcp should be loaded
await app.getMcpClient().listTools();
expect(app.mcp?.tools.length).toBeGreaterThan(0); expect(app.mcp?.tools.length).toBeGreaterThan(0);
}); });
}); });
+2 -1
View File
@@ -41,7 +41,7 @@ describe("mcp data", async () => {
beforeEach(async () => { beforeEach(async () => {
const time = performance.now(); const time = performance.now();
app = createApp({ app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
@@ -50,6 +50,7 @@ describe("mcp data", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {
+2 -1
View File
@@ -21,7 +21,7 @@ describe("mcp media", async () => {
beforeEach(async () => { beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
app = createApp({ app = createApp({
initialConfig: { config: {
media: { media: {
enabled: true, enabled: true,
adapter: { adapter: {
@@ -39,6 +39,7 @@ describe("mcp media", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
server.setLogLevel("error"); server.setLogLevel("error");
server.onNotification((message) => { server.onNotification((message) => {
+7 -2
View File
@@ -1,6 +1,10 @@
import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test"; import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils"; import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
/** /**
* - [x] config_server_get * - [x] config_server_get
@@ -11,7 +15,7 @@ describe("mcp system", async () => {
let server: McpServer; let server: McpServer;
beforeAll(async () => { beforeAll(async () => {
app = createApp({ app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
@@ -20,6 +24,7 @@ describe("mcp system", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
}); });
+2 -1
View File
@@ -14,7 +14,7 @@ describe("mcp system", async () => {
let server: McpServer; let server: McpServer;
beforeAll(async () => { beforeAll(async () => {
app = createApp({ app = createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
@@ -23,6 +23,7 @@ describe("mcp system", async () => {
}, },
}); });
await app.build(); await app.build();
await app.getMcpClient().ping();
server = app.mcp!; server = app.mcp!;
}); });
@@ -0,0 +1,80 @@
import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { syncConfig } from "plugins/dev/sync-config.plugin";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("syncConfig", () => {
it("should only sync if enabled", async () => {
const called = mock(() => null);
const app = createApp();
await app.build();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: false,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: true,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(1);
});
it("should respect secrets", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await app.build();
await syncConfig({
write: async (config) => {
called();
expect(config.auth.jwt.secret).toBe("test");
},
enabled: true,
includeSecrets: true,
includeFirstBoot: true,
})(app).onBuilt?.();
await syncConfig({
write: async (config) => {
called();
// it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON()
// but it's required to get the app running
expect(config.auth.jwt.secret).toBe("");
},
enabled: true,
includeSecrets: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(2);
});
});
+6 -2
View File
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { registries } from "../../src"; import { registries } from "../../src";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype"; import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("repros", async () => { describe("repros", async () => {
/** /**
@@ -88,7 +92,7 @@ describe("repros", async () => {
fns.relation(schema.product_likes).manyToOne(schema.users); fns.relation(schema.product_likes).manyToOne(schema.users);
}, },
); );
const app = createApp({ initialConfig: { data: schema.toJSON() } }); const app = createApp({ config: { data: schema.toJSON() } });
await app.build(); await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any; const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
+1 -1
View File
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events"; import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
+4 -4
View File
@@ -248,7 +248,7 @@ describe("Core Utils", async () => {
expect(utils.getContentName(request)).toBe(name); expect(utils.getContentName(request)).toBe(name);
}); });
test.only("detectImageDimensions", async () => { test("detectImageDimensions", async () => {
// wrong // wrong
// @ts-expect-error // @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow(); expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
@@ -267,12 +267,12 @@ describe("Core Utils", async () => {
}); });
describe("dates", () => { describe("dates", () => {
test.only("formats local time", () => { test("formats local time", () => {
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
console.log(utils.datetimeStringUTC(new Date())); /*console.log(utils.datetimeStringUTC(new Date()));
console.log(utils.datetimeStringUTC()); console.log(utils.datetimeStringUTC());
console.log(new Date()); console.log(new Date());
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */
}); });
}); });
}); });
+3 -2
View File
@@ -5,7 +5,8 @@ import { parse } from "core/utils/schema";
import { DataController } from "../../src/data/api/DataController"; import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema"; import { dataConfigSchema } from "../../src/data/data-schema";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult"; import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult"; import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
import { Entity, EntityManager, type EntityData } from "data/entities"; import { Entity, EntityManager, type EntityData } from "data/entities";
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
import { ManyToOneRelation } from "data/relations"; import { ManyToOneRelation } from "data/relations";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"])); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {}); const dataConfig = parse(dataConfigSchema, {});
+5 -2
View File
@@ -1,12 +1,15 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities"; import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations"; import { ManyToOneRelation } from "data/relations";
import { TextField } from "data/fields"; import { TextField } from "data/fields";
import { JoinBuilder } from "data/entities/query/JoinBuilder"; import { JoinBuilder } from "data/entities/query/JoinBuilder";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] JoinBuilder", async () => { describe("[data] JoinBuilder", async () => {
test("missing relation", async () => { test("missing relation", async () => {
+3 -2
View File
@@ -9,13 +9,14 @@ import {
} from "data/relations"; } from "data/relations";
import { NumberField, TextField } from "data/fields"; import { NumberField, TextField } from "data/fields";
import * as proto from "data/prototype"; import * as proto from "data/prototype";
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper"; import { getDummyConnection } from "../../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { MutatorEvents } from "data/events"; import { MutatorEvents } from "data/events";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"])); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog()); afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => { describe("[data] Mutator (base)", async () => {
+4 -2
View File
@@ -1,4 +1,4 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { Kysely, Transaction } from "kysely"; import type { Kysely, Transaction } from "kysely";
import { TextField } from "data/fields"; import { TextField } from "data/fields";
import { em as $em, entity as $entity, text as $text } from "data/prototype"; import { em as $em, entity as $entity, text as $text } from "data/prototype";
@@ -6,11 +6,13 @@ import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations"; import { ManyToOneRelation } from "data/relations";
import { RepositoryEvents } from "data/events"; import { RepositoryEvents } from "data/events";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
type E = Kysely<any> | Transaction<any>; type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection(); const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup); beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
async function sleep(ms: number) { async function sleep(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
+36 -1
View File
@@ -1,5 +1,5 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, spyOn, test } from "bun:test";
import { randomString } from "core/utils"; import { randomString } from "core/utils";
import { Entity, EntityManager } from "data/entities"; import { Entity, EntityManager } from "data/entities";
import { TextField, EntityIndex } from "data/fields"; import { TextField, EntityIndex } from "data/fields";
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
const diffAfter = await em.schema().getDiff(); const diffAfter = await em.schema().getDiff();
expect(diffAfter.length).toBe(0); expect(diffAfter.length).toBe(0);
}); });
test("returns statements", async () => {
const amount = 5;
const entities = new Array(amount)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const statements = await em.schema().sync({ force: true });
expect(statements.length).toBe(amount);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
});
test("batches statements", async () => {
const { dummyConnection } = getDummyConnection();
const entities = new Array(20)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const spy = spyOn(em.connection, "executeQueries");
const statements = await em.schema().sync();
expect(statements.length).toBe(entities.length);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
await em.schema().sync({ force: true });
expect(spy).toHaveBeenCalledTimes(1);
const tables = await em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute();
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
});
}); });
+5 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities"; import { Entity, EntityManager } from "data/entities";
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations"; import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
import { TextField } from "data/fields"; import { TextField } from "data/fields";
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
import { WithBuilder } from "data/entities/query/WithBuilder"; import { WithBuilder } from "data/entities/query/WithBuilder";
import { schemaToEm } from "../../helper"; import { schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
expect(index.name).toEqual("idx_test_name"); expect(index.name).toEqual("idx_test_name");
expect(index.unique).toEqual(false); expect(index.unique).toEqual(false);
}); });
test("it fails on non-unique", async () => {
const field = new TestField("name", { required: false });
expect(() => new EntityIndex(entity, [field], true)).toThrowError();
expect(() => new EntityIndex(entity, [field])).toBeDefined();
});
}); });
@@ -4,8 +4,10 @@ import {
type BaseRelationConfig, type BaseRelationConfig,
EntityRelation, EntityRelation,
EntityRelationAnchor, EntityRelationAnchor,
ManyToManyRelation,
RelationTypes, RelationTypes,
} from "data/relations"; } from "data/relations";
import * as proto from "data/prototype";
class TestEntityRelation extends EntityRelation { class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) { constructor(config?: BaseRelationConfig) {
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
const relation2 = new TestEntityRelation({ required: true }); const relation2 = new TestEntityRelation({ required: true });
expect(relation2.required).toBe(true); expect(relation2.required).toBe(true);
}); });
it("correctly produces the relation name", async () => {
const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"));
expect(relation.getName()).not.toContain(",");
expect(relation.getName()).toBe("mn_apps_organizations");
const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), {
connectionTableMappedName: "appOrganizations",
});
expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations");
});
}); });
+1 -21
View File
@@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" }; return { url: "http://127.0.0.1:8080" };
} }
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
const _oldConsoles = {
debug: console.debug,
log: console.log,
warn: console.warn,
error: console.error,
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}
export function compileQb(qb: SelectQueryBuilder<any, any, any>) { export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
return { sql, parameters }; return { sql, parameters };
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) { export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
console.log("$", sqlFormat(sql), "\n[params]", parameters); console.info("$", sqlFormat(sql), "\n[params]", parameters);
} }
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> { export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
@@ -1,12 +1,9 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares"; import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -66,9 +63,10 @@ const configs = {
}; };
function createAuthApp() { function createAuthApp() {
const { dummyConnection } = getDummyConnection();
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
initialConfig: { config: {
auth: configs.auth, auth: configs.auth,
}, },
}); });
@@ -151,8 +149,8 @@ describe("integration auth", () => {
const { data: users } = await app.em.repository("users").findMany(); const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2); expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email); expect(users[0]?.email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email); expect(users[1]?.email).toBe(configs.users.admin.email);
}); });
it("should log you in with API", async () => { it("should log you in with API", async () => {
@@ -223,7 +221,7 @@ describe("integration auth", () => {
app.server.get("/get", auth(), async (c) => { app.server.get("/get", auth(), async (c) => {
return c.json({ return c.json({
user: c.get("auth").user ?? null, user: c.get("auth")?.user ?? null,
}); });
}); });
app.server.get("/wait", auth(), async (c) => { app.server.get("/wait", auth(), async (c) => {
@@ -242,7 +240,7 @@ describe("integration auth", () => {
{ {
await new Promise((r) => setTimeout(r, 10)); await new Promise((r) => setTimeout(r, 10));
const res = await app.server.request("/get"); const res = await app.server.request("/get");
const data = await res.json(); const data = (await res.json()) as any;
expect(data.user).toBe(null); expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any }); expect(await $fns.me()).toEqual({ user: null as any });
} }
@@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { createApp } from "core/test/utils"; import { createApp } from "core/test/utils";
import { Api } from "../../src/Api"; import { Api } from "../../src/Api";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("integration config", () => { describe("integration config", () => {
it("should create an entity", async () => { it("should create an entity", async () => {
+5 -5
View File
@@ -6,17 +6,20 @@ import { createApp } from "core/test/utils";
import { mergeObject, randomString } from "../../src/core/utils"; import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema"; import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => { beforeAll(() => {
disableConsoleLog();
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
}); });
afterAll(enableConsoleLog);
const path = `${assetsPath}/image.png`; const path = `${assetsPath}/image.png`;
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) { async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({ const app = createApp({
initialConfig: { config: {
media: mergeObject( media: mergeObject(
{ {
enabled: true, enabled: true,
@@ -40,9 +43,6 @@ function makeName(ext: string) {
return randomString(10) + "." + ext; return randomString(10) + "." + ext;
} }
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => { describe("MediaController", () => {
test("accepts direct", async () => { test("accepts direct", async () => {
const app = await makeApp(); const app = await makeApp();
+8 -5
View File
@@ -3,11 +3,14 @@ import { createApp } from "core/test/utils";
import { AuthController } from "../../src/auth/api/AuthController"; import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "data/prototype"; import { em, entity, make, text } from "data/prototype";
import { AppAuth, type ModuleBuildContext } from "modules"; import { AppAuth, type ModuleBuildContext } from "modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppAuth", () => { describe("AppAuth", () => {
test.only("...", () => { test.skip("...", () => {
const auth = new AppAuth({}); const auth = new AppAuth({});
console.log(auth.toJSON()); console.log(auth.toJSON());
console.log(auth.config); console.log(auth.config);
@@ -147,7 +150,7 @@ describe("AppAuth", () => {
test("registers auth middleware for bknd routes only", async () => { test("registers auth middleware for bknd routes only", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
jwt: { jwt: {
@@ -177,7 +180,7 @@ describe("AppAuth", () => {
test("should allow additional user fields", async () => { test("should allow additional user fields", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
entity_name: "users", entity_name: "users",
enabled: true, enabled: true,
@@ -201,7 +204,7 @@ describe("AppAuth", () => {
test("ensure user field configs is always correct", async () => { test("ensure user field configs is always correct", async () => {
const app = createApp({ const app = createApp({
initialConfig: { config: {
auth: { auth: {
enabled: true, enabled: true,
}, },
+2 -2
View File
@@ -7,7 +7,7 @@ import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => { describe("AppMedia", () => {
test.only("...", () => { test.skip("...", () => {
const media = new AppMedia(); const media = new AppMedia();
console.log(media.toJSON()); console.log(media.toJSON());
}); });
@@ -18,7 +18,7 @@ describe("AppMedia", () => {
registries.media.register("local", StorageLocalAdapter); registries.media.register("local", StorageLocalAdapter);
const app = createApp({ const app = createApp({
initialConfig: { config: {
media: { media: {
entity_name: "media", entity_name: "media",
enabled: true, enabled: true,
@@ -0,0 +1,76 @@
import { it, expect, describe } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations";
describe("DbModuleManager", () => {
it("should extract secrets", async () => {
const { dummyConnection } = getDummyConnection();
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
await m.save();
});
it("should work with initial secrets", async () => {
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "test",
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
const getSecrets = () =>
db
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "secrets")
.executeTakeFirst()
.then((r) => r?.json);
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// also after rebuild
await m.build();
await m.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// and ignore if already present
const m2 = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "something completely different",
},
});
await m2.build();
await m2.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
});
});
+10 -5
View File
@@ -1,14 +1,19 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager"; import { getDefaultConfig } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations"; import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { s, stripMark } from "core/utils/schema"; import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype"; import { entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
test("s1: no config, no build", async () => { test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -133,7 +138,7 @@ describe("ModuleManager", async () => {
const db = c2.dummyConnection.kysely; const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, { const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json }, initial: { version: version - 1, ...json } as any,
}); });
await mm2.syncConfigTable(); await mm2.syncConfigTable();
await db await db
@@ -1,14 +1,22 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src"; import { App, type InitialModuleConfigs, createApp } from "/";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper"; import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json"; import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json"; import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json"; import v8_2 from "./samples/v8-2.json";
import v9 from "./samples/v9.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
// app expects migratable config to be present in database // app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs | any) { async function createVersionedApp(
config: InitialModuleConfigs | any,
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version"); if (!("version" in config)) throw new Error("config must have a version");
@@ -34,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
}) })
.execute(); .execute();
if (opts?.beforeCreateApp) {
await opts.beforeCreateApp(db);
}
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
}); });
@@ -41,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
return app; return app;
} }
async function getRawConfig(
app: App,
opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] },
) {
const db = app.em.connection.kysely;
return await db
.selectFrom("__bknd")
.selectAll()
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute();
}
describe("Migrations", () => { describe("Migrations", () => {
/** /**
* updated auth strategies to have "enabled" prop * updated auth strategies to have "enabled" prop
@@ -78,4 +103,30 @@ describe("Migrations", () => {
// @ts-expect-error // @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined(); expect(app.toJSON(true).server.admin).toBeUndefined();
}); });
test("migration from 9 to 10", async () => {
expect(v9.version).toBe(9);
const app = await createVersionedApp(v9);
expect(app.version()).toBeGreaterThan(9);
// @ts-expect-error
expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe(
"^^s3.secret_access_key^^",
);
const [config, secrets] = (await getRawConfig(app, {
version: 10,
types: ["config", "secrets"],
})) as any;
expect(config.json.auth.jwt.secret).toBe("");
expect(config.json.media.adapter.config.access_key).toBe("");
expect(config.json.media.adapter.config.secret_access_key).toBe("");
expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^");
expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^");
expect(secrets.json["media.adapter.config.secret_access_key"]).toBe(
"^^s3.secret_access_key^^",
);
});
}); });
@@ -0,0 +1,612 @@
{
"version": 9,
"server": {
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
],
"allow_credentials": true
},
"mcp": { "enabled": false, "path": "/api/system/mcp" }
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": { "type": "text", "config": { "required": true } },
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": { "type": "text", "config": { "required": false } },
"size": { "type": "number", "config": { "required": false } },
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": { "type": "text", "config": { "required": false } },
"modified_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"reference": { "type": "text", "config": { "required": false } },
"entity_id": { "type": "number", "config": { "required": false } },
"metadata": { "type": "json", "config": { "required": false } }
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"email": { "type": "text", "config": { "required": true } },
"strategy": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["password"] },
"required": true,
"hidden": ["update", "form"],
"fillable": ["create"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["admin", "guest"] },
"required": false
}
},
"age": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["18-24", "25-34", "35-44", "45-64", "65+"]
},
"required": false
}
},
"height": { "type": "number", "config": { "required": false } },
"gender": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["male", "female"] },
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"avatars": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"identifier": { "type": "text", "config": { "required": false } },
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"started_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"input": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"tryons": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"avatars_id": {
"type": "relation",
"config": {
"label": "Avatars",
"required": false,
"reference": "avatars",
"target": "avatars",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "tryons",
"max_items": 1
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Products",
"required": false,
"reference": "products",
"target": "products",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"enabled": { "type": "boolean", "config": { "required": false } },
"title": { "type": "text", "config": { "required": false } },
"url": { "type": "text", "config": { "required": false } },
"image": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products",
"max_items": 1
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"garment_type": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["auto", "tops", "bottoms", "one-pieces"]
},
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sites": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"origin": {
"type": "text",
"config": {
"pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$",
"required": true
}
},
"name": { "type": "text", "config": { "required": false } },
"active": { "type": "boolean", "config": { "required": false } },
"logo": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "sites",
"max_items": 1
}
},
"instructions": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": { "rows": "2" }
},
"required": false,
"hidden": ["table"]
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sessions": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": { "format": "uuid", "fillable": false, "required": false }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": true }
},
"claimed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"url": { "type": "text", "config": { "required": false } },
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
}
},
"relations": {
"poly_avatars_media_input": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "input" }
},
"poly_avatars_media_output": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "output" }
},
"n1_avatars_users": {
"type": "n:1",
"source": "avatars",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_avatars": {
"type": "n:1",
"source": "tryons",
"target": "avatars",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_users": {
"type": "n:1",
"source": "tryons",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_tryons_media_output": {
"type": "poly",
"source": "tryons",
"target": "media",
"config": { "mappedBy": "output", "targetCardinality": 1 }
},
"poly_products_media_image": {
"type": "poly",
"source": "products",
"target": "media",
"config": { "mappedBy": "image", "targetCardinality": 1 }
},
"n1_tryons_products": {
"type": "n:1",
"source": "tryons",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_sites_media_logo": {
"type": "poly",
"source": "sites",
"target": "media",
"config": { "mappedBy": "logo", "targetCardinality": 1 }
},
"n1_sessions_sites": {
"type": "n:1",
"source": "sessions",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_sessions_users": {
"type": "n:1",
"source": "sessions",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_products_sites": {
"type": "n:1",
"source": "products",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_sites_origin_active": {
"entity": "sites",
"fields": ["origin", "active"],
"unique": false
},
"idx_sites_active": {
"entity": "sites",
"fields": ["active"],
"unique": false
},
"idx_products_url": {
"entity": "products",
"fields": ["url"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "^^jwt.secret^^",
"alg": "HS256",
"expires": 999999999,
"issuer": "issuer",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "none",
"secure": true,
"httpOnly": true,
"expires": 604800,
"partitioned": false,
"renew": true,
"pathSuccess": "/admin",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": { "hashing": "sha256" }
}
},
"guard": { "enabled": false },
"roles": {
"admin": { "implicit_allow": true },
"guest": { "is_default": true }
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": { "body_max_size": 0 },
"adapter": {
"type": "s3",
"config": {
"access_key": "^^s3.access_key^^",
"secret_access_key": "^^s3.secret_access_key^^",
"url": "https://1234.r2.cloudflarestorage.com/bucket-name"
}
}
},
"flows": { "basepath": "/api/flows", "flows": {} }
}
+9 -4
View File
@@ -3,20 +3,25 @@ import c from "picocolors";
import { formatNumber } from "bknd/utils"; import { formatNumber } from "bknd/utils";
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
const deps = Object.keys(pkg.dependencies);
const external = ["jsonv-ts/*", "wrangler", ...deps];
if (process.env.DEBUG) { if (process.env.DEBUG) {
await esbuild.build({ const result = await esbuild.build({
entryPoints: ["./src/cli/index.ts"], entryPoints: ["./src/cli/index.ts"],
outdir: "./dist/cli", outdir: "./dist/cli",
platform: "node", platform: "node",
minify: false, minify: true,
format: "esm", format: "esm",
metafile: true,
bundle: true, bundle: true,
external: ["jsonv-ts", "jsonv-ts/*"], external,
define: { define: {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),
}, },
}); });
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
process.exit(0); process.exit(0);
} }
@@ -26,7 +31,7 @@ const result = await Bun.build({
outdir: "./dist/cli", outdir: "./dist/cli",
env: "PUBLIC_*", env: "PUBLIC_*",
minify: true, minify: true,
external: ["jsonv-ts", "jsonv-ts/*"], external,
define: { define: {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),
+2
View File
@@ -252,6 +252,8 @@ async function buildAdapters() {
platform: "neutral", platform: "neutral",
entry: ["src/adapter/index.ts"], entry: ["src/adapter/index.ts"],
outDir: "dist/adapter", outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}), }),
// specific adatpers // specific adatpers
+1 -1
View File
@@ -3,7 +3,7 @@ import { createApp } from "bknd/adapter/bun";
async function generate() { async function generate() {
console.info("Generating MCP documentation..."); console.info("Generating MCP documentation...");
const app = await createApp({ const app = await createApp({
initialConfig: { config: {
server: { server: {
mcp: { mcp: {
enabled: true, enabled: true,
+9 -7
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.17.2", "version": "0.18.0-rc.7",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -13,7 +13,7 @@
"bugs": { "bugs": {
"url": "https://github.com/bknd-io/bknd/issues" "url": "https://github.com/bknd-io/bknd/issues"
}, },
"packageManager": "bun@1.2.19", "packageManager": "bun@1.2.22",
"engines": { "engines": {
"node": ">=22.13" "node": ">=22.13"
}, },
@@ -30,7 +30,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts", "cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./", "prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md", "postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail", "test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node", "test:all": "bun run test && bun run test:node",
@@ -65,7 +65,7 @@
"hono": "4.8.3", "hono": "4.8.3",
"json-schema-library": "10.0.0-rc7", "json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.2", "jsonv-ts": "0.8.4",
"kysely": "0.27.6", "kysely": "0.27.6",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
@@ -78,10 +78,10 @@
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.2",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@cloudflare/vitest-pool-workers": "^0.8.38", "@cloudflare/vitest-pool-workers": "^0.9.3",
"@cloudflare/workers-types": "^4.20250606.0", "@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/vite-dev-server": "^0.19.1", "@hono/vite-dev-server": "^0.21.0",
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.9", "@libsql/client": "^0.15.9",
"@mantine/modals": "^7.17.1", "@mantine/modals": "^7.17.1",
@@ -130,7 +130,9 @@
"vite-plugin-circular-dependency": "^0.5.0", "vite-plugin-circular-dependency": "^0.5.0",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9", "vitest": "^3.0.9",
"wouter": "^3.6.0" "wouter": "^3.6.0",
"wrangler": "^4.37.1",
"miniflare": "^4.20250913.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@hono/node-server": "^1.14.3" "@hono/node-server": "^1.14.3"
+40 -24
View File
@@ -5,17 +5,18 @@ import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
ModuleManager,
type InitialModuleConfigs, type InitialModuleConfigs,
type ModuleBuildContext,
type ModuleConfigs, type ModuleConfigs,
type ModuleManagerOptions,
type Modules, type Modules,
ModuleManager,
type ModuleBuildContext,
type ModuleManagerOptions,
} from "modules/ModuleManager"; } from "modules/ModuleManager";
import { DbModuleManager } from "modules/db/DbModuleManager";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; import { SystemController } from "modules/server/SystemController";
import type { MaybePromise } from "core/types"; import type { MaybePromise, PartialRec } from "core/types";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers"; import type { IEmailDriver, ICacheDriver } from "core/drivers";
@@ -93,20 +94,23 @@ export type AppOptions = {
email?: IEmailDriver; email?: IEmailDriver;
cache?: ICacheDriver; cache?: ICacheDriver;
}; };
mode?: "db" | "code";
readonly?: boolean;
}; };
export type CreateAppConfig = { export type CreateAppConfig = {
/**
* bla
*/
connection?: Connection | { url: string }; connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs; config?: PartialRec<ModuleConfigs>;
options?: AppOptions; options?: AppOptions;
}; };
export type AppConfig = InitialModuleConfigs; export type AppConfig = { version: number } & ModuleConfigs;
export type LocalApiOptions = Request | ApiOptions; export type LocalApiOptions = Request | ApiOptions;
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> { export class App<
C extends Connection = Connection,
Config extends PartialRec<ModuleConfigs> = PartialRec<ModuleConfigs>,
Options extends AppOptions = AppOptions,
> {
static readonly Events = AppEvents; static readonly Events = AppEvents;
modules: ModuleManager; modules: ModuleManager;
@@ -121,8 +125,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
constructor( constructor(
public connection: C, public connection: C,
_initialConfig?: InitialModuleConfigs, _config?: Config,
private options?: Options, public options?: Options,
) { ) {
this.drivers = options?.drivers ?? {}; this.drivers = options?.drivers ?? {};
@@ -134,9 +138,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.plugins.set(config.name, config); this.plugins.set(config.name, config);
} }
this.runPlugins("onBoot"); this.runPlugins("onBoot");
this.modules = new ModuleManager(connection, {
// use db manager by default
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
this.modules = new Manager(connection, {
...(options?.manager ?? {}), ...(options?.manager ?? {}),
initial: _initialConfig, initial: _config,
onUpdated: this.onUpdated.bind(this), onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this), onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this), onServerInit: this.onServerInit.bind(this),
@@ -145,6 +153,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.modules.ctx().emgr.registerEvents(AppEvents); this.modules.ctx().emgr.registerEvents(AppEvents);
} }
get mode() {
return this.options?.mode ?? "db";
}
isReadOnly() {
return Boolean(this.mode === "code" || this.options?.readonly);
}
get emgr() { get emgr() {
return this.modules.ctx().emgr; return this.modules.ctx().emgr;
} }
@@ -175,7 +191,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return results as any; return results as any;
} }
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) { async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
// prevent multiple concurrent builds // prevent multiple concurrent builds
if (this._building) { if (this._building) {
while (this._building) { while (this._building) {
@@ -188,7 +204,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = true; this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true; if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch }); await this.modules.build();
const { guard } = this.modules.ctx(); const { guard } = this.modules.ctx();
@@ -215,10 +231,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = false; this._building = false;
} }
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.mutateConfigSafe(module);
}
get server() { get server() {
return this.modules.server; return this.modules.server;
} }
@@ -232,6 +244,10 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
} }
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
if (!this.isBuilt()) {
throw new Error("App is not built yet, run build() first");
}
return this.server.fetch as any; return this.server.fetch as any;
} }
@@ -290,13 +306,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
} }
getMcpClient() { getMcpClient() {
if (!this.mcp) { const config = this.modules.get("server").config.mcp;
if (!config.enabled) {
throw new Error("MCP is not enabled"); throw new Error("MCP is not enabled");
} }
const mcpPath = this.modules.get("server").config.mcp.path;
return new McpClient({ return new McpClient({
url: "http://localhost" + mcpPath, url: "http://localhost" + config.path,
fetch: this.server.request, fetch: this.server.request,
}); });
} }
@@ -377,5 +393,5 @@ export function createApp(config: CreateAppConfig = {}) {
throw new Error("Invalid connection"); throw new Error("Invalid connection");
} }
return new App(config.connection, config.initialConfig, config.options); return new App(config.connection, config.config, config.options);
} }
+14 -21
View File
@@ -1,6 +1,7 @@
import type { TestRunner } from "core/test"; import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index"; import type { BkndConfig, DefaultArgs } from "./index";
import type { App } from "App"; import type { App } from "App";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
export function adapterTestSuite< export function adapterTestSuite<
Config extends BkndConfig = BkndConfig, Config extends BkndConfig = BkndConfig,
@@ -13,24 +14,17 @@ export function adapterTestSuite<
label = "app", label = "app",
overrides = {}, overrides = {},
}: { }: {
makeApp: ( makeApp: (config: Config, args?: Args) => Promise<App>;
config: Config, makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
label?: string; label?: string;
overrides?: { overrides?: {
dbUrl?: string; dbUrl?: string;
}; };
}, },
) { ) {
const { test, expect, mock } = testRunner; const { test, expect, mock, beforeAll, afterAll } = testRunner;
const id = crypto.randomUUID(); beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
test(`creates ${label}`, async () => { test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any; const beforeBuild = mock(async () => null) as any;
@@ -39,7 +33,7 @@ export function adapterTestSuite<
const config = { const config = {
app: (env) => ({ app: (env) => ({
connection: { url: env.url }, connection: { url: env.url },
initialConfig: { config: {
server: { cors: { origin: env.origin } }, server: { cors: { origin: env.origin } },
}, },
}), }),
@@ -53,11 +47,10 @@ export function adapterTestSuite<
url: overrides.dbUrl ?? ":memory:", url: overrides.dbUrl ?? ":memory:",
origin: "localhost", origin: "localhost",
} as any, } as any,
{ force: false, id },
); );
expect(app).toBeDefined(); expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost"); expect(app.toJSON().server.cors.origin).toEqual("localhost");
expect(beforeBuild).toHaveBeenCalledTimes(1); expect(beforeBuild).toHaveBeenCalledTimes(2);
expect(onBuilt).toHaveBeenCalledTimes(1); expect(onBuilt).toHaveBeenCalledTimes(1);
}); });
@@ -68,8 +61,8 @@ export function adapterTestSuite<
return { res, data }; return { res, data };
}; };
test("responds with the same app id", async () => { /* test.skip("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { force: false, id }); const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher); const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
@@ -77,14 +70,14 @@ export function adapterTestSuite<
expect(data.server.cors.origin).toEqual("localhost"); expect(data.server.cors.origin).toEqual("localhost");
}); });
test("creates fresh & responds to api config", async () => { test.skip("creates fresh & responds to api config", async () => {
// set the same id, but force recreate // set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id, force: true }); const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher); const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*"); expect(data.server.cors.origin).toEqual("*");
}); }); */
} }
} }
+1 -1
View File
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("astro adapter", () => { describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp, makeApp: astro.getApp,
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }), makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
}); });
}); });
+4 -9
View File
@@ -1,4 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv; type AstroEnv = NodeJS.ProcessEnv;
type TAstro = { type TAstro = {
@@ -9,17 +9,12 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>( export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {}, config: AstroBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts: FrameworkOptions = {},
) { ) {
return await createFrameworkApp(config, args ?? import.meta.env, opts); return await createFrameworkApp(config, args ?? import.meta.env);
} }
export function serve<Env = AstroEnv>( export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (fnArgs: TAstro) => { return async (fnArgs: TAstro) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
}; };
} }
+2 -5
View File
@@ -1,7 +1,7 @@
import type { App } from "bknd"; import type { App } from "bknd";
import { handle } from "hono/aws-lambda"; import { handle } from "hono/aws-lambda";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
type AwsLambdaEnv = object; type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> = export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
@@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>( export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {}, { adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
): Promise<App> { ): Promise<App> {
let additional: Partial<RuntimeBkndConfig> = { let additional: Partial<RuntimeBkndConfig> = {
adminOptions, adminOptions,
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
...additional, ...additional,
}, },
args ?? process.env, args ?? process.env,
opts,
); );
} }
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>( export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {}, config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
return async (event) => { return async (event) => {
const app = await createApp(config, args, opts); const app = await createApp(config, args);
return await handle(app.server)(event); return await handle(app.server)(event);
}; };
} }
+2 -2
View File
@@ -11,8 +11,8 @@ describe("aws adapter", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: awsLambda.createApp, makeApp: awsLambda.createApp,
// @todo: add a request to lambda event translator? // @todo: add a request to lambda event translator?
makeHandler: (c, a, o) => async (request: Request) => { makeHandler: (c, a) => async (request: Request) => {
const app = await awsLambda.createApp(c, a, o); const app = await awsLambda.createApp(c, a);
return app.fetch(request); return app.fetch(request);
}, },
}); });
+4 -9
View File
@@ -1,7 +1,7 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "."; import { registerLocalMediaAdapter } from ".";
import { config, type App } from "bknd"; import { config, type App } from "bknd";
import type { ServeOptions } from "bun"; import type { ServeOptions } from "bun";
@@ -13,7 +13,6 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
export async function createApp<Env = BunEnv>( export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {}, { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter(); registerLocalMediaAdapter();
@@ -28,19 +27,17 @@ export async function createApp<Env = BunEnv>(
...config, ...config,
}, },
args ?? (process.env as Env), args ?? (process.env as Env),
opts,
); );
} }
export function createHandler<Env = BunEnv>( export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {}, config: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts); app = await createApp(config, args ?? (process.env as Env));
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -50,7 +47,7 @@ export function serve<Env = BunEnv>(
{ {
distPath, distPath,
connection, connection,
initialConfig, config: _config,
options, options,
port = config.server.default_port, port = config.server.default_port,
onBuilt, onBuilt,
@@ -60,7 +57,6 @@ export function serve<Env = BunEnv>(
...serveOptions ...serveOptions
}: BunBkndConfig<Env> = {}, }: BunBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
Bun.serve({ Bun.serve({
...serveOptions, ...serveOptions,
@@ -68,7 +64,7 @@ export function serve<Env = BunEnv>(
fetch: createHandler( fetch: createHandler(
{ {
connection, connection,
initialConfig, config: _config,
options, options,
onBuilt, onBuilt,
buildConfig, buildConfig,
@@ -77,7 +73,6 @@ export function serve<Env = BunEnv>(
serveStatic, serveStatic,
}, },
args, args,
opts,
), ),
}); });
@@ -1,8 +1,9 @@
import { connectionTestSuite } from "data/connection/connection-test-suite"; import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection"; import { bunSqlite } from "./BunSqliteConnection";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test"; import { describe, test, mock, expect } from "bun:test";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
describe("BunSqliteConnection", () => { describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, { connectionTestSuite(bunTestRunner, {
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
}), }),
rawDialectDetails: [], rawDialectDetails: [],
}); });
test("onCreateConnection", async () => {
const called = mock(() => null);
const conn = bunSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(Database);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(Database);
expect(called).toHaveBeenCalledTimes(1);
});
}); });
@@ -1,40 +1,53 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { genericSqlite, type GenericSqliteConnection } from "bknd"; import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { omitKeys } from "bknd/utils";
export type BunSqliteConnection = GenericSqliteConnection<Database>; export type BunSqliteConnection = GenericSqliteConnection<Database>;
export type BunSqliteConnectionConfig = { export type BunSqliteConnectionConfig = Omit<
database: Database; GenericSqliteConnectionConfig<Database>,
}; "name" | "supports"
> &
({ database?: Database; url?: never } | { url?: string; database?: never });
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) { export function bunSqlite(config?: BunSqliteConnectionConfig) {
let db: Database; let db: Database | undefined;
if (config) { if (config) {
if ("database" in config) { if ("database" in config && config.database) {
db = config.database; db = config.database;
} else { } else if (config.url) {
db = new Database(config.url); db = new Database(config.url);
} }
} else { }
if (!db) {
db = new Database(":memory:"); db = new Database(":memory:");
} }
return genericSqlite("bun-sqlite", db, (utils) => { return genericSqlite(
//const fn = cache ? "query" : "prepare"; "bun-sqlite",
const getStmt = (sql: string) => db.prepare(sql); db,
(utils) => {
const getStmt = (sql: string) => db.prepare(sql);
return { return {
db, db,
query: utils.buildQueryFn({ query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])), all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => { run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || [])); const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return { return {
insertId: utils.parseBigInt(lastInsertRowid), insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes), numAffectedRows: utils.parseBigInt(changes),
}; };
}, },
}), }),
close: () => db.close(), close: () => db.close(),
}; };
}); },
omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
);
} }
+2 -1
View File
@@ -1,4 +1,4 @@
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test"; import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test";
export const bunTestRunner = { export const bunTestRunner = {
describe, describe,
@@ -8,4 +8,5 @@ export const bunTestRunner = {
beforeEach, beforeEach,
afterEach, afterEach,
afterAll, afterAll,
beforeAll,
}; };
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
const staticConfig = await makeConfig( const staticConfig = await makeConfig(
{ {
connection: { url: DB_URL }, connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } }, config: { data: { basepath: DB_URL } },
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
); );
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined(); expect(staticConfig.connection).toBeDefined();
const dynamicConfig = await makeConfig( const dynamicConfig = await makeConfig(
{ {
app: (env) => ({ app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } }, config: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL }, connection: { url: env.DB_URL },
}), }),
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
); );
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined(); expect(dynamicConfig.connection).toBeDefined();
}); });
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, { adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a, o) => { makeApp: async (c, a) => {
return await createApp(c, { env: a } as any, o); return await createApp(c, { env: a } as any);
}, },
makeHandler: (c, a, o) => { makeHandler: (c, a) => {
console.log("args", a); console.log("args", a);
return async (request: any) => { return async (request: any) => {
const app = await createApp( const app = await createApp(
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
connection: { url: DB_URL }, connection: { url: DB_URL },
}, },
a as any, a as any,
o,
); );
return app.fetch(request); return app.fetch(request);
}; };
@@ -5,7 +5,7 @@ import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import type { MaybePromise } from "bknd"; import type { MaybePromise } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
declare global { declare global {
@@ -34,12 +34,8 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
}; };
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>( export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>, config: CloudflareBkndConfig<Env> = {},
ctx: Partial<CloudflareContext<Env>> = {}, ctx: Partial<CloudflareContext<Env>> = {},
opts: RuntimeOptions = {
// by default, require the app to be rebuilt every time
force: true,
},
) { ) {
const appConfig = await makeConfig( const appConfig = await makeConfig(
{ {
@@ -53,7 +49,7 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
}, },
ctx, ctx,
); );
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts); return await createRuntimeApp<Env>(appConfig, ctx?.env);
} }
// compatiblity // compatiblity
@@ -1,11 +1,12 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite"; import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare"; import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection"; import { doSqlite } from "./DoConnection";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const script = ` const script = `
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
@@ -40,6 +41,9 @@ export default {
} }
`; `;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("doSqlite", async () => { describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, { connectionTestSuite(viTestRunner, {
makeConnection: async () => { makeConnection: async () => {
@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite"; import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
import { Miniflare } from "miniflare"; import { Miniflare } from "miniflare";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("cacheWorkersKV", async () => { describe("cacheWorkersKV", async () => {
beforeAll(() => { beforeAll(() => {
+19 -7
View File
@@ -5,8 +5,9 @@ import {
type CloudflareBkndConfig, type CloudflareBkndConfig,
type CloudflareEnv, type CloudflareEnv,
} from "bknd/adapter/cloudflare"; } from "bknd/adapter/cloudflare";
import type { PlatformProxy } from "wrangler"; import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
import process from "node:process"; import process from "node:process";
import { $console } from "bknd/utils";
export type WithPlatformProxyOptions = { export type WithPlatformProxyOptions = {
/** /**
@@ -14,22 +15,27 @@ export type WithPlatformProxyOptions = {
* You can override/force this by setting this option. * You can override/force this by setting this option.
*/ */
useProxy?: boolean; useProxy?: boolean;
proxyOptions?: GetPlatformProxyOptions;
}; };
export function withPlatformProxy<Env extends CloudflareEnv>( export function withPlatformProxy<Env extends CloudflareEnv>(
config?: CloudflareBkndConfig<Env>, config: CloudflareBkndConfig<Env> = {},
opts?: WithPlatformProxyOptions, opts?: WithPlatformProxyOptions,
) { ) {
const use_proxy = const use_proxy =
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1"; typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
let proxy: PlatformProxy | undefined; let proxy: PlatformProxy | undefined;
$console.log("Using cloudflare platform proxy");
async function getEnv(env?: Env): Promise<Env> { async function getEnv(env?: Env): Promise<Env> {
if (use_proxy) { if (use_proxy) {
if (!proxy) { if (!proxy) {
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy); const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
proxy = await getPlatformProxy(); proxy = await getPlatformProxy(opts?.proxyOptions);
setTimeout(proxy?.dispose, 1000); process.on("exit", () => {
proxy?.dispose();
});
} }
return proxy.env as unknown as Env; return proxy.env as unknown as Env;
} }
@@ -50,16 +56,22 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
// @ts-ignore // @ts-ignore
app: async (_env) => { app: async (_env) => {
const env = await getEnv(_env); const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
if (config?.app === undefined && use_proxy) { if (config?.app === undefined && use_proxy && binding) {
const binding = getBinding(env, "D1Database");
return { return {
connection: d1Sqlite({ connection: d1Sqlite({
binding: binding.value, binding: binding.value,
}), }),
}; };
} else if (typeof config?.app === "function") { } else if (typeof config?.app === "function") {
return config?.app(env); const appConfig = await config?.app(env);
if (binding) {
appConfig.connection = d1Sqlite({
binding: binding.value,
}) as any;
}
return appConfig;
} }
return config?.app || {}; return config?.app || {};
}, },
@@ -3,8 +3,12 @@ import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter"; import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import path from "node:path"; import path from "node:path";
import { describe, afterAll, test, expect } from "vitest"; import { describe, afterAll, test, expect, beforeAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
let mf: Miniflare | undefined; let mf: Miniflare | undefined;
describe("StorageR2Adapter", async () => { describe("StorageR2Adapter", async () => {
+172 -35
View File
@@ -24,45 +24,157 @@ export function devFsVitePlugin({
projectRoot = config.root; projectRoot = config.root;
}, },
configureServer(server) { configureServer(server) {
if (!isDev) return; if (!isDev) {
verbose && console.debug("[dev-fs-plugin] Not in dev mode, skipping");
return;
}
// Track active chunked requests
const activeRequests = new Map<
string,
{
totalChunks: number;
filename: string;
chunks: string[];
receivedChunks: number;
}
>();
// Intercept stdout to watch for our write requests // Intercept stdout to watch for our write requests
const originalStdoutWrite = process.stdout.write; const originalStdoutWrite = process.stdout.write;
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) { process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
const output = chunk.toString(); const output = chunk.toString();
// Check if this output contains our special write request // Skip our own debug output
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) { if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
try { // @ts-ignore
// Extract the JSON from the log line // biome-ignore lint/style/noArguments: <explanation>
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/); return originalStdoutWrite.apply(process.stdout, arguments);
if (match) { }
const writeRequest = JSON.parse(match[1]);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
if (verbose) {
console.debug("[dev-fs-plugin] Intercepted write request via stdout");
}
// Process the write request immediately // Track if we process any protocol messages (to suppress output)
(async () => { let processedProtocolMessage = false;
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
await nodeWriteFile(fullPath, writeRequest.data);
if (verbose) {
console.debug("[dev-fs-plugin] File written successfully!");
}
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Don't output the raw write request to console // Process all start markers in this output
return true; if (output.includes("{{DEV_FS_START}}")) {
const startMatches = [
...output.matchAll(/{{DEV_FS_START}} ([a-z0-9]+) (\d+) (.+)/g),
];
for (const startMatch of startMatches) {
const requestId = startMatch[1];
const totalChunks = Number.parseInt(startMatch[2]);
const filename = startMatch[3];
activeRequests.set(requestId, {
totalChunks,
filename,
chunks: new Array(totalChunks),
receivedChunks: 0,
});
verbose &&
console.debug(
`[dev-fs-plugin] Started request ${requestId} for ${filename} (${totalChunks} chunks)`,
);
}
processedProtocolMessage = true;
}
// Process all chunk data in this output
if (output.includes("{{DEV_FS_CHUNK}}")) {
const chunkMatches = [
...output.matchAll(/{{DEV_FS_CHUNK}} ([a-z0-9]+) (\d+) ([A-Za-z0-9+/=]+)/g),
];
for (const chunkMatch of chunkMatches) {
const requestId = chunkMatch[1];
const chunkIndex = Number.parseInt(chunkMatch[2]);
const chunkData = chunkMatch[3];
const request = activeRequests.get(requestId);
if (request) {
request.chunks[chunkIndex] = chunkData;
request.receivedChunks++;
verbose &&
console.debug(
`[dev-fs-plugin] Received chunk ${chunkIndex}/${request.totalChunks - 1} for ${request.filename} (length: ${chunkData.length})`,
);
// Validate base64 chunk
if (chunkData.length < 1000 && chunkIndex < request.totalChunks - 1) {
verbose &&
console.warn(
`[dev-fs-plugin] WARNING: Chunk ${chunkIndex} seems truncated (length: ${chunkData.length})`,
);
} }
} }
} catch (error) {
// Not a valid write request, continue with normal output
} }
processedProtocolMessage = true;
}
// Process all end markers in this output
if (output.includes("{{DEV_FS_END}}")) {
const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)];
for (const endMatch of endMatches) {
const requestId = endMatch[1];
const request = activeRequests.get(requestId);
if (request && request.receivedChunks === request.totalChunks) {
try {
// Reconstruct the base64 string
const fullBase64 = request.chunks.join("");
verbose &&
console.debug(
`[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`,
);
// Decode and parse
const decodedJson = atob(fullBase64);
const writeRequest = JSON.parse(decodedJson);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
verbose &&
console.debug(
`[dev-fs-plugin] Processing write request for ${writeRequest.filename}`,
);
// Process the write request
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
verbose &&
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
await nodeWriteFile(fullPath, writeRequest.data);
verbose &&
console.debug("[dev-fs-plugin] File written successfully!");
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Clean up
activeRequests.delete(requestId);
return true;
}
} catch (error) {
console.error(
"[dev-fs-plugin] Error processing chunked request:",
String(error),
);
activeRequests.delete(requestId);
}
} else if (request) {
verbose &&
console.debug(
`[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`,
);
}
}
processedProtocolMessage = true;
}
// If we processed any protocol messages, suppress output
if (processedProtocolMessage) {
return callback ? callback() : true;
} }
// @ts-ignore // @ts-ignore
@@ -78,7 +190,10 @@ export function devFsVitePlugin({
// @ts-ignore // @ts-ignore
transform(code, id, options) { transform(code, id, options) {
// Only transform in SSR mode during development // Only transform in SSR mode during development
if (!isDev || !options?.ssr) return; //if (!isDev || !options?.ssr) return;
if (!isDev) {
return;
}
// Check if this is the bknd config file // Check if this is the bknd config file
if (id.includes(configFile)) { if (id.includes(configFile)) {
@@ -92,7 +207,7 @@ export function devFsVitePlugin({
if (typeof globalThis !== 'undefined') { if (typeof globalThis !== 'undefined') {
globalThis.__devFsPolyfill = { globalThis.__devFsPolyfill = {
writeFile: async (filename, data) => { writeFile: async (filename, data) => {
${verbose ? "console.debug('dev-fs polyfill: Intercepting write request for', filename);" : ""} ${verbose ? "console.debug('[dev-fs-polyfill] Intercepting write request for', filename);" : ""}
// Use console logging as a communication channel // Use console logging as a communication channel
// The main process will watch for this specific log pattern // The main process will watch for this specific log pattern
@@ -103,16 +218,38 @@ if (typeof globalThis !== 'undefined') {
timestamp: Date.now() timestamp: Date.now()
}; };
// Output as a specially formatted console message // Output as a specially formatted console message with end delimiter
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest)); // Base64 encode the JSON to avoid any control character issues
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""} const jsonString = JSON.stringify(writeRequest);
const encodedJson = btoa(jsonString);
// Split into reasonable chunks that balance performance vs reliability
const chunkSize = 2000; // 2KB chunks - safe for most environments
const chunks = [];
for (let i = 0; i < encodedJson.length; i += chunkSize) {
chunks.push(encodedJson.slice(i, i + chunkSize));
}
const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
// Send start marker (use stdout.write to avoid console display)
process.stdout.write('{{DEV_FS_START}} ' + requestId + ' ' + chunks.length + ' ' + filename + '\\n');
// Send each chunk
chunks.forEach((chunk, index) => {
process.stdout.write('{{DEV_FS_CHUNK}} ' + requestId + ' ' + index + ' ' + chunk + '\\n');
});
// Send end marker
process.stdout.write('{{DEV_FS_END}} ' + requestId + '\\n');
return Promise.resolve(); return Promise.resolve();
} }
}; };
} }`;
`;
return polyfill + code; return polyfill + code;
} else {
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
} }
}, },
} satisfies Plugin; } satisfies Plugin;
+19 -38
View File
@@ -13,21 +13,14 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
export type BkndConfig<Args = any> = CreateAppConfig & { export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>); app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>);
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>; beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
}; };
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>; export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type CreateAdapterAppOptions = {
force?: boolean;
id?: string;
};
export type FrameworkOptions = CreateAdapterAppOptions;
export type RuntimeOptions = CreateAdapterAppOptions;
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & { export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string; distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
@@ -41,7 +34,7 @@ export type DefaultArgs = {
export async function makeConfig<Args = DefaultArgs>( export async function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>, config: BkndConfig<Args>,
args?: Args, args?: Args,
): Promise<CreateAppConfig> { ): Promise<Omit<BkndConfig<Args>, "app">> {
let additionalConfig: CreateAppConfig = {}; let additionalConfig: CreateAppConfig = {};
const { app, ...rest } = config; const { app, ...rest } = config;
if (app) { if (app) {
@@ -59,45 +52,34 @@ export async function makeConfig<Args = DefaultArgs>(
} }
// a map that contains all apps by id // a map that contains all apps by id
const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>( export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config, config: Config = {} as Config,
args?: Args, args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> { ): Promise<App> {
const id = opts?.id ?? "app"; await config.beforeBuild?.(undefined, $registries);
let app = apps.get(id);
if (!app || opts?.force) {
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
app = App.create(appConfig); const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
if (!opts?.force) { let connection: Connection | undefined;
apps.set(id, app); if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
} }
appConfig.connection = connection;
} }
return app; return App.create(appConfig);
} }
export async function createFrameworkApp<Args = DefaultArgs>( export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {}, config: FrameworkBkndConfig = {},
args?: Args, args?: Args,
opts?: FrameworkOptions,
): Promise<App> { ): Promise<App> {
const app = await createAdapterApp(config, args, opts); const app = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
if (config.onBuilt) { if (config.onBuilt) {
@@ -120,9 +102,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
export async function createRuntimeApp<Args = DefaultArgs>( export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {}, { serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args, args?: Args,
opts?: RuntimeOptions,
): Promise<App> { ): Promise<App> {
const app = await createAdapterApp(config, args, opts); const app = await createAdapterApp(config, args);
if (!app.isBuilt()) { if (!app.isBuilt()) {
app.emgr.onEvent( app.emgr.onEvent(
+3 -5
View File
@@ -1,4 +1,4 @@
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter"; import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
import { isNode } from "bknd/utils"; import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
@@ -10,9 +10,8 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>( export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>, config: NextjsBkndConfig<Env>,
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return await createFrameworkApp(config, args ?? (process.env as Env), opts); return await createFrameworkApp(config, args ?? (process.env as Env));
} }
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -41,10 +40,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>( export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {}, { cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return async (req: Request) => { return async (req: Request) => {
const app = await getApp(config, args, opts); const app = await getApp(config, args);
const request = getCleanRequest(req, cleanRequest); const request = getCleanRequest(req, cleanRequest);
return app.fetch(request); return app.fetch(request);
}; };
@@ -1,19 +1,29 @@
import { genericSqlite } from "bknd"; import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import { omitKeys } from "bknd/utils";
export type NodeSqliteConnectionConfig = { export type NodeSqliteConnection = GenericSqliteConnection<DatabaseSync>;
database: DatabaseSync; export type NodeSqliteConnectionConfig = Omit<
}; GenericSqliteConnectionConfig<DatabaseSync>,
"name" | "supports"
> &
({ database?: DatabaseSync; url?: never } | { url?: string; database?: never });
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) { export function nodeSqlite(config?: NodeSqliteConnectionConfig) {
let db: DatabaseSync; let db: DatabaseSync | undefined;
if (config) { if (config) {
if ("database" in config) { if ("database" in config && config.database) {
db = config.database; db = config.database;
} else { } else if (config.url) {
db = new DatabaseSync(config.url); db = new DatabaseSync(config.url);
} }
} else { }
if (!db) {
db = new DatabaseSync(":memory:"); db = new DatabaseSync(":memory:");
} }
@@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
"node-sqlite", "node-sqlite",
db, db,
(utils) => { (utils) => {
const getStmt = (sql: string) => { const getStmt = (sql: string) => db.prepare(sql);
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return { return {
db, db,
@@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
}; };
}, },
{ {
...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
supports: { supports: {
batching: false, batching: false,
}, },
@@ -1,8 +1,13 @@
import { nodeSqlite } from "./NodeSqliteConnection"; import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite"; import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite"; import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe } from "vitest"; import { describe, beforeAll, afterAll, test, expect, vi } from "vitest";
import { viTestRunner } from "../vitest"; import { viTestRunner } from "../vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("NodeSqliteConnection", () => { describe("NodeSqliteConnection", () => {
connectionTestSuite(viTestRunner, { connectionTestSuite(viTestRunner, {
@@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => {
}), }),
rawDialectDetails: [], rawDialectDetails: [],
}); });
test("onCreateConnection", async () => {
const called = vi.fn(() => null);
const conn = nodeSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(DatabaseSync);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(DatabaseSync);
expect(called).toHaveBeenCalledOnce();
});
}); });
+3 -7
View File
@@ -2,7 +2,7 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server"; import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/storage"; import { registerLocalMediaAdapter } from "adapter/node/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { config as $config, type App } from "bknd"; import { config as $config, type App } from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
@@ -18,7 +18,6 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>( export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {}, { distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
const root = path.relative( const root = path.relative(
process.cwd(), process.cwd(),
@@ -36,19 +35,17 @@ export async function createApp<Env = NodeEnv>(
}, },
// @ts-ignore // @ts-ignore
args ?? { env: process.env }, args ?? { env: process.env },
opts,
); );
} }
export function createHandler<Env = NodeEnv>( export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {}, config: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
let app: App | undefined; let app: App | undefined;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts); app = await createApp(config, args ?? (process.env as Env));
} }
return app.fetch(req); return app.fetch(req);
}; };
@@ -57,13 +54,12 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>( export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {}, { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: RuntimeOptions,
) { ) {
honoServe( honoServe(
{ {
port, port,
hostname, hostname,
fetch: createHandler(config, args, opts), fetch: createHandler(config, args),
}, },
(connInfo) => { (connInfo) => {
$console.log(`Server is running on http://localhost:${connInfo.port}`); $console.log(`Server is running on http://localhost:${connInfo.port}`);
@@ -2,10 +2,6 @@ import { describe, beforeAll, afterAll } from "vitest";
import * as node from "./node.adapter"; import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite"; import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("node adapter", () => { describe("node adapter", () => {
adapterTestSuite(viTestRunner, { adapterTestSuite(viTestRunner, {
@@ -1,9 +1,13 @@
import { describe } from "vitest"; import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest"; import { viTestRunner } from "adapter/node/vitest";
import { StorageLocalAdapter } from "adapter/node"; import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite"; import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("StorageLocalAdapter (node)", async () => { describe("StorageLocalAdapter (node)", async () => {
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets"); const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
+3 -2
View File
@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict"; import nodeAssert from "node:assert/strict";
import { test, describe, beforeEach, afterEach } from "node:test"; import { test, describe, beforeEach, afterEach, after, before } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test"; import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls // Track mock function calls
@@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = {
}), }),
beforeEach: beforeEach, beforeEach: beforeEach,
afterEach: afterEach, afterEach: afterEach,
afterAll: () => {}, afterAll: after,
beforeAll: before,
}; };
+2 -1
View File
@@ -1,5 +1,5 @@
import type { TestFn, TestRunner, Test } from "core/test"; import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; import { describe, test, expect, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) { function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any); return test(label, fn as any);
@@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = {
beforeEach: beforeEach, beforeEach: beforeEach,
afterEach: afterEach, afterEach: afterEach,
afterAll: afterAll, afterAll: afterAll,
beforeAll: beforeAll,
}; };
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("react-router adapter", () => { describe("react-router adapter", () => {
adapterTestSuite(bunTestRunner, { adapterTestSuite(bunTestRunner, {
makeApp: rr.getApp, makeApp: rr.getApp,
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }), makeHandler: (c, a) => (request: Request) => rr.serve(c, a?.env)({ request }),
}); });
}); });
@@ -1,5 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import type { FrameworkOptions } from "adapter";
type ReactRouterEnv = NodeJS.ProcessEnv; type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = { type ReactRouterFunctionArgs = {
@@ -10,17 +9,15 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>( export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>, config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return await createFrameworkApp(config, args ?? process.env, opts); return await createFrameworkApp(config, args ?? process.env);
} }
export function serve<Env = ReactRouterEnv>( export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {}, config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env, args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return async (fnArgs: ReactRouterFunctionArgs) => { return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request); return (await getApp(config, args)).fetch(fnArgs.request);
}; };
} }
+3 -9
View File
@@ -1,7 +1,7 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
async function createApp<ViteEnv>( async function createApp<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {}, config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv, env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> { ): Promise<App> {
registerLocalMediaAdapter(); registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
], ],
}, },
env, env,
opts,
); );
} }
export function serve<ViteEnv>( export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env, opts); const app = await createApp(config, env);
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
}, },
}; };
+6 -3
View File
@@ -278,7 +278,9 @@ export class Authenticator<
} }
return payload as any; return payload as any;
} catch (e) {} } catch (e) {
$console.debug("Authenticator jwt verify error", String(e));
}
return; return;
} }
@@ -396,8 +398,9 @@ export class Authenticator<
if (headers.has("Authorization")) { if (headers.has("Authorization")) {
const bearerHeader = String(headers.get("Authorization")); const bearerHeader = String(headers.get("Authorization"));
token = bearerHeader.replace("Bearer ", ""); token = bearerHeader.replace("Bearer ", "");
} else if (is_context) { } else {
token = await this.getAuthCookie(c as Context); const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
token = await this.getAuthCookie(context);
} }
if (token) { if (token) {
+11 -1
View File
@@ -4,6 +4,7 @@ import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import c from "picocolors"; import c from "picocolors";
import { withConfigOptions } from "cli/utils/options"; import { withConfigOptions } from "cli/utils/options";
import { $console } from "bknd/utils";
export const config: CliCommand = (program) => { export const config: CliCommand = (program) => {
withConfigOptions(program.command("config")) withConfigOptions(program.command("config"))
@@ -19,7 +20,14 @@ export const config: CliCommand = (program) => {
config = getDefaultConfig(); config = getDefaultConfig();
} else { } else {
const app = await makeAppFromEnv(options); const app = await makeAppFromEnv(options);
config = app.toJSON(options.secrets); const manager = app.modules;
if (options.secrets) {
$console.warn("Including secrets in output");
config = manager.toJSON(true);
} else {
config = manager.extractSecrets().configs;
}
} }
config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config); config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config);
@@ -31,5 +39,7 @@ export const config: CliCommand = (program) => {
} else { } else {
console.info(JSON.parse(config)); console.info(JSON.parse(config));
} }
process.exit(0);
}); });
}; };
+1
View File
@@ -34,4 +34,5 @@ async function action(options: { out?: string; clean?: boolean }) {
// biome-ignore lint/suspicious/noConsoleLog: // biome-ignore lint/suspicious/noConsoleLog:
console.log(c.green(`Assets copied to: ${c.bold(out)}`)); console.log(c.green(`Assets copied to: ${c.bold(out)}`));
process.exit(0);
} }
+2
View File
@@ -40,7 +40,9 @@ const subjects = {
async function action(subject: string) { async function action(subject: string) {
if (subject in subjects) { if (subject in subjects) {
await subjects[subject](); await subjects[subject]();
process.exit(0);
} else { } else {
console.error("Invalid subject: ", subject); console.error("Invalid subject: ", subject);
process.exit(1);
} }
} }
+1
View File
@@ -8,3 +8,4 @@ export { copyAssets } from "./copy-assets";
export { types } from "./types"; export { types } from "./types";
export { mcp } from "./mcp/mcp"; export { mcp } from "./mcp/mcp";
export { sync } from "./sync"; export { sync } from "./sync";
export { secrets } from "./secrets";
+12 -2
View File
@@ -9,18 +9,28 @@ export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number]; export type Platform = (typeof PLATFORMS)[number];
export async function serveStatic(server: Platform): Promise<MiddlewareHandler> { export async function serveStatic(server: Platform): Promise<MiddlewareHandler> {
const onNotFound = (path: string) => {
$console.debug("Couldn't resolve static file at", path);
};
switch (server) { switch (server) {
case "node": { case "node": {
const m = await import("@hono/node-server/serve-static"); const m = await import("@hono/node-server/serve-static");
const root = getRelativeDistPath() + "/static";
$console.debug("Serving static files from", root);
return m.serveStatic({ return m.serveStatic({
// somehow different for node // somehow different for node
root: getRelativeDistPath() + "/static", root,
onNotFound,
}); });
} }
case "bun": { case "bun": {
const m = await import("hono/bun"); const m = await import("hono/bun");
const root = path.resolve(getRelativeDistPath(), "static");
$console.debug("Serving static files from", root);
return m.serveStatic({ return m.serveStatic({
root: path.resolve(getRelativeDistPath(), "static"), root,
onNotFound,
}); });
} }
} }
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Config } from "@libsql/client/node";
import { StorageLocalAdapter } from "adapter/node/storage"; import { StorageLocalAdapter } from "adapter/node/storage";
import type { CliBkndConfig, CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import { config, type App, type CreateAppConfig } from "bknd"; import { config, type App, type CreateAppConfig, type MaybePromise } from "bknd";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import c from "picocolors"; import c from "picocolors";
@@ -60,7 +60,7 @@ type MakeAppConfig = {
connection?: CreateAppConfig["connection"]; connection?: CreateAppConfig["connection"];
server?: { platform?: Platform }; server?: { platform?: Platform };
setAdminHtml?: boolean; setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => MaybePromise<void>;
}; };
async function makeApp(config: MakeAppConfig) { async function makeApp(config: MakeAppConfig) {
+2 -2
View File
@@ -8,7 +8,7 @@ export const schema: CliCommand = (program) => {
.option("--pretty", "pretty print") .option("--pretty", "pretty print")
.action((options) => { .action((options) => {
const schema = getDefaultSchema(); const schema = getDefaultSchema();
// biome-ignore lint/suspicious/noConsoleLog: console.info(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema)); process.exit(0);
}); });
}; };
+59
View File
@@ -0,0 +1,59 @@
import type { CliCommand } from "../types";
import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
import { transformObject } from "bknd/utils";
import { Option } from "commander";
export const secrets: CliCommand = (program) => {
withConfigOptions(program.command("secrets"))
.description("get app secrets")
.option("--template", "template output without the actual secrets")
.addOption(
new Option("--format <format>", "format output").choices(["json", "env"]).default("json"),
)
.option("--out <file>", "output file")
.action(
async (
options: WithConfigOptions<{ template: string; format: "json" | "env"; out: string }>,
) => {
const app = await makeAppFromEnv(options);
const manager = app.modules;
let secrets = manager.extractSecrets().secrets;
if (options.template) {
secrets = transformObject(secrets, () => "");
}
console.info("");
if (options.out) {
if (options.format === "env") {
await writeFile(
options.out,
Object.entries(secrets)
.map(([key, value]) => `${key}=${value}`)
.join("\n"),
);
} else {
await writeFile(options.out, JSON.stringify(secrets, null, 2));
}
console.info(`Secrets written to ${c.cyan(options.out)}`);
} else {
if (options.format === "env") {
console.info(
c.cyan(
Object.entries(secrets)
.map(([key, value]) => `${key}=${value}`)
.join("\n"),
),
);
} else {
console.info(secrets);
}
}
console.info("");
process.exit(0);
},
);
};
+17 -11
View File
@@ -7,13 +7,14 @@ import { withConfigOptions } from "cli/utils/options";
export const sync: CliCommand = (program) => { export const sync: CliCommand = (program) => {
withConfigOptions(program.command("sync")) withConfigOptions(program.command("sync"))
.description("sync database") .description("sync database")
.option("--dump", "dump operations to console instead of executing them") .option("--force", "perform database syncing operations")
.option("--drop", "include destructive DDL operations") .option("--drop", "include destructive DDL operations")
.option("--out <file>", "output file") .option("--out <file>", "output file")
.option("--sql", "use sql output") .option("--sql", "use sql output")
.action(async (options) => { .action(async (options) => {
const app = await makeAppFromEnv(options); const app = await makeAppFromEnv(options);
const schema = app.em.schema(); const schema = app.em.schema();
console.info(c.dim("Checking database state..."));
const stmts = await schema.sync({ drop: options.drop }); const stmts = await schema.sync({ drop: options.drop });
console.info(""); console.info("");
@@ -24,22 +25,27 @@ export const sync: CliCommand = (program) => {
// @todo: currently assuming parameters aren't used // @todo: currently assuming parameters aren't used
const sql = stmts.map((d) => d.sql).join(";\n") + ";"; const sql = stmts.map((d) => d.sql).join(";\n") + ";";
if (options.dump) { if (options.force) {
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
await schema.sync({ force: true, drop: options.drop });
console.info(`\n${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`);
} else {
if (options.out) { if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2); const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output); await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`); console.info(`SQL written to ${c.cyan(options.out)}`);
} else { } else {
console.info(options.sql ? c.cyan(sql) : stmts); console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info(
c.yellow(
"\nNo statements have been executed. Use --force to perform database syncing operations",
),
);
} }
process.exit(0);
} }
process.exit(0);
await schema.sync({ force: true, drop: options.drop });
console.info(c.cyan(sql));
console.info(`${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`);
}); });
}; };
+2
View File
@@ -35,4 +35,6 @@ async function action({
await writeFile(outfile, et.toString()); await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`); console.info(`\nTypes written to ${c.cyan(outfile)}`);
} }
process.exit(0);
} }
+5
View File
@@ -78,9 +78,11 @@ async function create(app: App, options: any) {
password: await strategy.hash(password as string), password: await strategy.hash(password as string),
}); });
$log.success(`Created user: ${c.cyan(created.email)}`); $log.success(`Created user: ${c.cyan(created.email)}`);
process.exit(0);
} catch (e) { } catch (e) {
$log.error("Error creating user"); $log.error("Error creating user");
$console.error(e); $console.error(e);
process.exit(1);
} }
} }
@@ -121,8 +123,10 @@ async function update(app: App, options: any) {
if (await app.module.auth.changePassword(user.id, password)) { if (await app.module.auth.changePassword(user.id, password)) {
$log.success(`Updated user: ${c.cyan(user.email)}`); $log.success(`Updated user: ${c.cyan(user.email)}`);
process.exit(0);
} else { } else {
$log.error("Error updating user"); $log.error("Error updating user");
process.exit(1);
} }
} }
@@ -158,4 +162,5 @@ async function token(app: App, options: any) {
console.log( console.log(
`\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`, `\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`,
); );
process.exit(0);
} }
+4 -2
View File
@@ -11,10 +11,12 @@ export interface AppEntity<IdType = number | string> {
export interface DB { export interface DB {
// make sure to make unknown as "any" // make sure to make unknown as "any"
[key: string]: { /* [key: string]: {
id: PrimaryFieldType; id: PrimaryFieldType;
[key: string]: any; [key: string]: any;
}; }; */
// @todo: that's not good, but required for admin options
[key: string]: any;
} }
export const config = { export const config = {
+1
View File
@@ -31,6 +31,7 @@ export type TestRunner = {
beforeEach: (fn: () => MaybePromise<void>) => void; beforeEach: (fn: () => MaybePromise<void>) => void;
afterEach: (fn: () => MaybePromise<void>) => void; afterEach: (fn: () => MaybePromise<void>) => void;
afterAll: (fn: () => MaybePromise<void>) => void; afterAll: (fn: () => MaybePromise<void>) => void;
beforeAll: (fn: () => MaybePromise<void>) => void;
}; };
export async function retry<T>( export async function retry<T>(
+2
View File
@@ -4,3 +4,5 @@ export interface Serializable<Class, Json extends object = object> {
} }
export type MaybePromise<T> = T | Promise<T>; export type MaybePromise<T> = T | Promise<T>;
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
+32
View File
@@ -396,6 +396,38 @@ export function getPath(
} }
} }
export function setPath(object: object, _path: string | (string | number)[], value: any) {
let path = _path;
// Optional string-path support.
// You can remove this `if` block if you don't need it.
if (typeof path === "string") {
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
path = path
.split(/[.\[\]]+/)
.filter((x) => x)
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
}
if (path.length === 0) {
throw new Error("The path must have at least one entry in it");
}
const [head, ...tail] = path as any;
if (tail.length === 0) {
object[head] = value;
return object;
}
if (!(head in object)) {
object[head] = typeof tail[0] === "number" ? [] : {};
}
setPath(object[head], tail, value);
return object;
}
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string { export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
const nl = indent ? "\n" : ""; const nl = indent ? "\n" : "";
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : ""); const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
+36 -3
View File
@@ -12,6 +12,7 @@ export {
getMcpServer, getMcpServer,
stdioTransport, stdioTransport,
McpClient, McpClient,
logLevels as mcpLogLevels,
type McpClientConfig, type McpClientConfig,
type ToolAnnotation, type ToolAnnotation,
type ToolHandlerCtx, type ToolHandlerCtx,
@@ -21,8 +22,35 @@ export { secret, SecretSchema } from "./secret";
export { s }; export { s };
export const stripMark = <O extends object>(o: O): O => o; const symbol = Symbol("bknd-validation-mark");
export const mark = <O extends object>(o: O): O => o;
export function stripMark<O = any>(obj: O) {
const newObj = structuredClone(obj);
mark(newObj, false);
return newObj as O;
}
export function mark(obj: any, validated = true) {
try {
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
if (validated) {
obj[symbol] = true;
} else {
delete obj[symbol];
}
for (const key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
mark(obj[key], validated);
}
}
}
} catch (e) {}
}
export function isMarked(obj: any) {
if (typeof obj !== "object" || obj === null) return false;
return obj[symbol] === true;
}
export const stringIdentifier = s.string({ export const stringIdentifier = s.string({
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$", pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
@@ -38,7 +66,8 @@ export class InvalidSchemaError extends Error {
) { ) {
super( super(
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` + `Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
`Error: ${JSON.stringify(errors[0], null, 2)}`, `Error: ${JSON.stringify(errors[0], null, 2)}\n\n` +
`Schema: ${JSON.stringify(schema.toJSON(), null, 2)}`,
); );
} }
@@ -73,6 +102,10 @@ export function parse<S extends s.Schema, Options extends ParseOptions = ParseOp
v: unknown, v: unknown,
opts?: Options, opts?: Options,
): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> { ): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> {
if (!opts?.forceParse && !opts?.coerce && isMarked(v)) {
return v as any;
}
const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema; const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema;
let value = let value =
opts?.coerce !== false opts?.coerce !== false
+9
View File
@@ -6,6 +6,8 @@ const _oldConsoles = {
warn: console.warn, warn: console.warn,
error: console.error, error: console.error,
}; };
let _oldStderr: any;
let _oldStdout: any;
export async function withDisabledConsole<R>( export async function withDisabledConsole<R>(
fn: () => Promise<R>, fn: () => Promise<R>,
@@ -36,10 +38,17 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
severities.forEach((severity) => { severities.forEach((severity) => {
console[severity] = () => null; console[severity] = () => null;
}); });
// Disable stderr
_oldStderr = process.stderr.write;
_oldStdout = process.stdout.write;
process.stderr.write = () => true;
process.stdout.write = () => true;
$console?.setLevel("critical"); $console?.setLevel("critical");
} }
export function enableConsoleLog() { export function enableConsoleLog() {
process.stderr.write = _oldStderr;
process.stdout.write = _oldStdout;
Object.entries(_oldConsoles).forEach(([severity, fn]) => { Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn; console[severity as ConsoleSeverity] = fn;
}); });
+3
View File
@@ -42,6 +42,9 @@ export class DataApi extends ModuleApi<DataApiOptions> {
) { ) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData; type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = RepositoryResultJSON<Data>; type T = RepositoryResultJSON<Data>;
// @todo: if none found, still returns meta...
return this.readMany(entity, { return this.readMany(entity, {
...query, ...query,
limit: 1, limit: 1,
+1
View File
@@ -60,6 +60,7 @@ export class DataController extends Controller {
"/sync", "/sync",
permission(DataPermissions.databaseSync), permission(DataPermissions.databaseSync),
mcpTool("data_sync", { mcpTool("data_sync", {
// @todo: should be removed if readonly
annotations: { annotations: {
destructiveHint: true, destructiveHint: true,
}, },
+12
View File
@@ -230,3 +230,15 @@ export function customIntrospector<T extends Constructor<Dialect>>(
}, },
}; };
} }
export class DummyConnection extends Connection {
override name = "dummy";
constructor() {
super(undefined as any);
}
override getFieldSchema(): SchemaResponse {
throw new Error("Method not implemented.");
}
}
@@ -4,6 +4,7 @@ import { getPath } from "bknd/utils";
import * as proto from "data/prototype"; import * as proto from "data/prototype";
import { createApp } from "App"; import { createApp } from "App";
import type { MaybePromise } from "core/types"; import type { MaybePromise } from "core/types";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
// @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc. // @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc.
// @todo: add toDriver/fromDriver tests on all types and fields // @todo: add toDriver/fromDriver tests on all types and fields
@@ -21,7 +22,9 @@ export function connectionTestSuite(
rawDialectDetails: string[]; rawDialectDetails: string[];
}, },
) { ) {
const { test, expect, describe, beforeEach, afterEach, afterAll } = testRunner; const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("base", () => { describe("base", () => {
let ctx: Awaited<ReturnType<typeof makeConnection>>; let ctx: Awaited<ReturnType<typeof makeConnection>>;
@@ -247,7 +250,7 @@ export function connectionTestSuite(
const app = createApp({ const app = createApp({
connection: ctx.connection, connection: ctx.connection,
initialConfig: { config: {
data: schema.toJSON(), data: schema.toJSON(),
}, },
}); });
@@ -333,7 +336,7 @@ export function connectionTestSuite(
const app = createApp({ const app = createApp({
connection: ctx.connection, connection: ctx.connection,
initialConfig: { config: {
data: schema.toJSON(), data: schema.toJSON(),
}, },
}); });
@@ -1,7 +1,6 @@
import type { KyselyPlugin, QueryResult } from "kysely"; import type { KyselyPlugin, QueryResult } from "kysely";
import { import {
type IGenericSqlite, type IGenericSqlite,
type OnCreateConnection,
type Promisable, type Promisable,
parseBigInt, parseBigInt,
buildQueryFn, buildQueryFn,
@@ -9,6 +8,7 @@ import {
} from "kysely-generic-sqlite"; } from "kysely-generic-sqlite";
import { SqliteConnection } from "./SqliteConnection"; import { SqliteConnection } from "./SqliteConnection";
import type { ConnQuery, ConnQueryResults, Features } from "../Connection"; import type { ConnQuery, ConnQueryResults, Features } from "../Connection";
import type { MaybePromise } from "bknd";
export type { IGenericSqlite }; export type { IGenericSqlite };
export type TStatement = { sql: string; parameters?: any[] | readonly any[] }; export type TStatement = { sql: string; parameters?: any[] | readonly any[] };
@@ -16,11 +16,11 @@ export interface IGenericCustomSqlite<DB = unknown> extends IGenericSqlite<DB> {
batch?: (stmts: TStatement[]) => Promisable<QueryResult<any>[]>; batch?: (stmts: TStatement[]) => Promisable<QueryResult<any>[]>;
} }
export type GenericSqliteConnectionConfig = { export type GenericSqliteConnectionConfig<Database = unknown> = {
name?: string; name?: string;
additionalPlugins?: KyselyPlugin[]; additionalPlugins?: KyselyPlugin[];
excludeTables?: string[]; excludeTables?: string[];
onCreateConnection?: OnCreateConnection; onCreateConnection?: (db: Database) => MaybePromise<void>;
supports?: Partial<Features>; supports?: Partial<Features>;
}; };
@@ -35,7 +35,12 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
) { ) {
super({ super({
dialect: GenericSqliteDialect, dialect: GenericSqliteDialect,
dialectArgs: [executor, config?.onCreateConnection], dialectArgs: [
executor,
config?.onCreateConnection && typeof config.onCreateConnection === "function"
? (c: any) => config.onCreateConnection?.(c.db.db as any)
: undefined,
],
additionalPlugins: config?.additionalPlugins, additionalPlugins: config?.additionalPlugins,
excludeTables: config?.excludeTables, excludeTables: config?.excludeTables,
}); });
@@ -61,7 +66,6 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> { override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
const executor = await this.getExecutor(); const executor = await this.getExecutor();
if (!executor.batch) { if (!executor.batch) {
//$console.debug("Batching is not supported by this database");
return super.executeQueries(...qbs); return super.executeQueries(...qbs);
} }
+1 -6
View File
@@ -34,7 +34,6 @@ export class EntityManager<TBD extends object = DefaultDB> {
private _entities: Entity[] = []; private _entities: Entity[] = [];
private _relations: EntityRelation[] = []; private _relations: EntityRelation[] = [];
private _indices: EntityIndex[] = []; private _indices: EntityIndex[] = [];
private _schema?: SchemaManager;
readonly emgr: EventManager<typeof EntityManager.Events>; readonly emgr: EventManager<typeof EntityManager.Events>;
static readonly Events = { ...MutatorEvents, ...RepositoryEvents }; static readonly Events = { ...MutatorEvents, ...RepositoryEvents };
@@ -249,11 +248,7 @@ export class EntityManager<TBD extends object = DefaultDB> {
} }
schema() { schema() {
if (!this._schema) { return new SchemaManager(this);
this._schema = new SchemaManager(this);
}
return this._schema;
} }
// @todo: centralize and add tests // @todo: centralize and add tests
@@ -0,0 +1,24 @@
import { describe, it, expect } from "bun:test";
import { EntityTypescript } from "./EntityTypescript";
import * as proto from "../prototype";
import { DummyConnection } from "../connection/Connection";
describe("EntityTypescript", () => {
it("should generate correct typescript for system entities", () => {
const schema = proto.em(
{
test: proto.entity("test", {
name: proto.text(),
}),
users: proto.systemEntity("users", {
name: proto.text(),
}),
},
({ relation }, { test, users }) => {
relation(test).manyToOne(users);
},
);
const et = new EntityTypescript(schema.proto.withConnection(new DummyConnection()));
expect(et.toString()).toContain('users?: DB["users"];');
});
});
+3 -3
View File
@@ -40,7 +40,7 @@ const systemEntities = {
export class EntityTypescript { export class EntityTypescript {
constructor( constructor(
protected em: EntityManager, protected em: EntityManager<any>,
protected _options: EntityTypescriptOptions = {}, protected _options: EntityTypescriptOptions = {},
) {} ) {}
@@ -50,7 +50,7 @@ export class EntityTypescript {
indentWidth: 2, indentWidth: 2,
indentChar: " ", indentChar: " ",
entityCommentMultiline: true, entityCommentMultiline: true,
fieldCommentMultiline: false, fieldCommentMultiline: true,
}; };
} }
@@ -82,7 +82,7 @@ export class EntityTypescript {
} }
typeName(name: string) { typeName(name: string) {
return autoFormatString(name); return autoFormatString(name).replace(/ /g, "");
} }
fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) { fieldTypesToString(type: TEntityTSType, opts?: { ignore_fields?: string[]; indent?: number }) {
+2 -2
View File
@@ -72,12 +72,12 @@ export class Result<T = unknown> {
return this.first().parameters; return this.first().parameters;
} }
get data() { get data(): T {
if (this.options.single) { if (this.options.single) {
return this.first().data?.[0]; return this.first().data?.[0];
} }
return this.first().data ?? []; return this.first().data ?? ([] as T);
} }
async execute(qb: Compilable | Compilable[]) { async execute(qb: Compilable | Compilable[]) {
+1
View File
@@ -4,5 +4,6 @@ export * from "./mutation/Mutator";
export * from "./query/Repository"; export * from "./query/Repository";
export * from "./query/WhereBuilder"; export * from "./query/WhereBuilder";
export * from "./query/WithBuilder"; export * from "./query/WithBuilder";
export * from "./Result";
export * from "./query/RepositoryResult"; export * from "./query/RepositoryResult";
export * from "./mutation/MutatorResult"; export * from "./mutation/MutatorResult";
@@ -15,17 +15,6 @@ export class EntityIndex {
throw new Error("All fields must be instances of Field"); throw new Error("All fields must be instances of Field");
} }
if (unique) {
const firstRequired = fields[0]?.isRequired();
if (!firstRequired) {
throw new Error(
`Unique indices must have first field as required: ${fields
.map((f) => f.name)
.join(", ")}`,
);
}
}
if (!name) { if (!name) {
this.name = [ this.name = [
unique ? "idx_unique" : "idx", unique ? "idx_unique" : "idx",
+2 -2
View File
@@ -210,8 +210,8 @@ type SystemEntities = {
export function systemEntity< export function systemEntity<
E extends keyof SystemEntities, E extends keyof SystemEntities,
Fields extends Record<string, Field<any, any, any>>, Fields extends Record<string, Field<any, any, any>>,
>(name: E, fields: Fields) { >(name: E, fields: Fields, config?: EntityConfig) {
return entity<E, SystemEntities[E] & Fields>(name, fields as any); return entity<E, SystemEntities[E] & Fields>(name, fields as any, config, "system");
} }
export function relation<Local extends Entity>(local: Local) { export function relation<Local extends Entity>(local: Local) {
+9 -2
View File
@@ -180,8 +180,15 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
override getName(): string { override getName(): string {
return [ return [
super.getName(), ...Array.from(
[this.connectionEntity.name, this.connectionTableMappedName].filter(Boolean), new Set(
[
this.type().replace(":", ""),
this.connectionEntity.name,
this.connectionTableMappedName,
].filter(Boolean),
),
),
].join("_"); ].join("_");
} }
} }
@@ -6,6 +6,7 @@ import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./
import { EntityRelationAnchor } from "./EntityRelationAnchor"; import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { type RelationType, RelationTypes } from "./relation-types"; import { type RelationType, RelationTypes } from "./relation-types";
import { s } from "bknd/utils"; import { s } from "bknd/utils";
import type { PrimaryFieldType } from "bknd";
export type PolymorphicRelationConfig = s.Static<typeof PolymorphicRelation.schema>; export type PolymorphicRelationConfig = s.Static<typeof PolymorphicRelation.schema>;
@@ -70,7 +71,7 @@ export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelati
.groupBy(groupBy); .groupBy(groupBy);
} }
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> { override getReferenceQuery(entity: Entity, id: PrimaryFieldType): Partial<RepoQuery> {
const info = this.queryInfo(entity); const info = this.queryInfo(entity);
return { return {
+17 -29
View File
@@ -247,20 +247,16 @@ export class SchemaManager {
async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) { async sync(config: { force?: boolean; drop?: boolean } = { force: false, drop: false }) {
const diff = await this.getDiff(); const diff = await this.getDiff();
let updates: number = 0;
const statements: { sql: string; parameters: readonly unknown[] }[] = []; const statements: { sql: string; parameters: readonly unknown[] }[] = [];
const schema = this.em.connection.kysely.schema; const schema = this.em.connection.kysely.schema;
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
for (const table of diff) { for (const table of diff) {
const qbs: { compile(): CompiledQuery; execute(): Promise<void> }[] = [];
let local_updates: number = 0;
const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add); const addFieldSchemas = this.collectFieldSchemas(table.name, table.columns.add);
const dropFields = table.columns.drop; const dropFields = table.columns.drop;
const dropIndices = table.indices.drop; const dropIndices = table.indices.drop;
if (table.isDrop) { if (table.isDrop) {
updates++;
local_updates++;
if (config.drop) { if (config.drop) {
qbs.push(schema.dropTable(table.name)); qbs.push(schema.dropTable(table.name));
} }
@@ -268,8 +264,6 @@ export class SchemaManager {
let createQb = schema.createTable(table.name); let createQb = schema.createTable(table.name);
// add fields // add fields
for (const fieldSchema of addFieldSchemas) { for (const fieldSchema of addFieldSchemas) {
updates++;
local_updates++;
// @ts-ignore // @ts-ignore
createQb = createQb.addColumn(...fieldSchema); createQb = createQb.addColumn(...fieldSchema);
} }
@@ -280,8 +274,6 @@ export class SchemaManager {
if (addFieldSchemas.length > 0) { if (addFieldSchemas.length > 0) {
// add fields // add fields
for (const fieldSchema of addFieldSchemas) { for (const fieldSchema of addFieldSchemas) {
updates++;
local_updates++;
// @ts-ignore // @ts-ignore
qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema)); qbs.push(schema.alterTable(table.name).addColumn(...fieldSchema));
} }
@@ -291,8 +283,6 @@ export class SchemaManager {
if (config.drop && dropFields.length > 0) { if (config.drop && dropFields.length > 0) {
// drop fields // drop fields
for (const column of dropFields) { for (const column of dropFields) {
updates++;
local_updates++;
qbs.push(schema.alterTable(table.name).dropColumn(column)); qbs.push(schema.alterTable(table.name).dropColumn(column));
} }
} }
@@ -310,35 +300,33 @@ export class SchemaManager {
qb = qb.unique(); qb = qb.unique();
} }
qbs.push(qb); qbs.push(qb);
local_updates++;
updates++;
} }
// drop indices // drop indices
if (config.drop) { if (config.drop) {
for (const index of dropIndices) { for (const index of dropIndices) {
qbs.push(schema.dropIndex(index)); qbs.push(schema.dropIndex(index));
local_updates++;
updates++;
} }
} }
}
if (local_updates === 0) continue; if (qbs.length > 0) {
statements.push(
...qbs.map((qb) => {
const { sql, parameters } = qb.compile();
return { sql, parameters };
}),
);
// iterate through built qbs $console.debug(
// @todo: run in batches "[SchemaManager]",
for (const qb of qbs) { `${qbs.length} statements\n${statements.map((stmt) => stmt.sql).join(";\n")}`,
const { sql, parameters } = qb.compile(); );
statements.push({ sql, parameters });
if (config.force) { try {
try { await this.em.connection.executeQueries(...qbs);
$console.debug("[SchemaManager]", sql); } catch (e) {
await qb.execute(); throw new Error(`Failed to execute batch: ${String(e)}`);
} catch (e) {
throw new Error(`Failed to execute query: ${sql}: ${(e as any).message}`);
}
}
} }
} }

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