Compare commits

...

49 Commits

Author SHA1 Message Date
dswbx 7d89fe4894 feat: enhance entity field management with configurable order
Added optional `fields_order` to the entity configuration schema, allowing for custom field sorting. Updated `getFields` method to support sorting based on this configuration. Adjusted related methods and UI components to handle field order during updates and serialization. Improved handling of virtual fields in various contexts.
2025-09-20 14:00:19 +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
169 changed files with 3365 additions and 1511 deletions
+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",
}, },
undefined, onBuilt: () => {
{ id }, called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
{ foo: "bar" },
); );
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,
+1 -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: {
+6 -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,
}, },
+1 -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,
+1 -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: {
+6 -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,
+1 -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,
@@ -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) => {
+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,4 +1,4 @@
import { describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src"; import { type InitialModuleConfigs, createApp } from "../../../src";
import { type Kysely, sql } from "kysely"; import { type Kysely, sql } from "kysely";
@@ -6,6 +6,10 @@ 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 { 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) {
+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,
+7 -5
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.4",
"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": {
@@ -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",
@@ -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"
+33 -21
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;
} }
@@ -377,5 +389,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,25 +1,36 @@
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",
db,
(utils) => {
const getStmt = (sql: string) => db.prepare(sql); const getStmt = (sql: string) => db.prepare(sql);
return { return {
@@ -36,5 +47,7 @@ export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string })
}), }),
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,
}; };
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog); /* beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog); */
describe("cf adapter", () => { describe("cf adapter", () => {
const DB_URL = ":memory:"; const DB_URL = ":memory:";
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
const staticConfig = await makeConfig( const staticConfig = await makeConfig(
{ {
connection: { url: DB_URL }, connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } }, config: { data: { basepath: DB_URL } },
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
); );
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined(); expect(staticConfig.connection).toBeDefined();
const dynamicConfig = await makeConfig( const dynamicConfig = await makeConfig(
{ {
app: (env) => ({ app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } }, config: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL }, connection: { url: env.DB_URL },
}), }),
}, },
$ctx({ DB_URL }), $ctx({ DB_URL }),
); );
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined(); expect(dynamicConfig.connection).toBeDefined();
}); });
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, { adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a, o) => { makeApp: async (c, a) => {
return await createApp(c, { env: a } as any, o); return await createApp(c, { env: a } as any);
}, },
makeHandler: (c, a, o) => { makeHandler: (c, a) => {
console.log("args", a); console.log("args", a);
return async (request: any) => { return async (request: any) => {
const app = await createApp( const app = await createApp(
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
connection: { url: DB_URL }, connection: { url: DB_URL },
}, },
a as any, a as any,
o,
); );
return app.fetch(request); return app.fetch(request);
}; };
@@ -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 () => {
+161 -24
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)
let processedProtocolMessage = false;
// Process all start markers in this output
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})`,
);
}
}
}
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 () => { (async () => {
try { try {
const fullPath = resolve(projectRoot, writeRequest.filename); const fullPath = resolve(projectRoot, writeRequest.filename);
verbose &&
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
await nodeWriteFile(fullPath, writeRequest.data); await nodeWriteFile(fullPath, writeRequest.data);
if (verbose) { verbose &&
console.debug("[dev-fs-plugin] File written successfully!"); console.debug("[dev-fs-plugin] File written successfully!");
}
} catch (error) { } catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error); console.error("[dev-fs-plugin] Error writing file:", error);
} }
})(); })();
// Don't output the raw write request to console // Clean up
activeRequests.delete(requestId);
return true; return true;
} }
}
} catch (error) { } catch (error) {
// Not a valid write request, continue with normal output 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;
+9 -28
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,15 +52,12 @@ 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); const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) { if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined; let connection: Connection | undefined;
@@ -82,22 +72,14 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
appConfig.connection = connection; appConfig.connection = connection;
} }
app = App.create(appConfig); return App.create(appConfig);
if (!opts?.force) {
apps.set(id, app);
}
}
return app;
} }
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.log("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.log("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) : "");
+2 -1
View File
@@ -38,7 +38,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)}`,
); );
} }
+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,
}, },
@@ -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);
} }
+29 -6
View File
@@ -18,6 +18,7 @@ export const entityConfigSchema = s
sort_field: s.string({ default: config.data.default_primary_field }), sort_field: s.string({ default: config.data.default_primary_field }),
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
primary_format: s.string({ enum: primaryFieldTypes }), primary_format: s.string({ enum: primaryFieldTypes }),
fields_order: s.array(s.string()).optional(),
}, },
{ default: {} }, { default: {} },
) )
@@ -137,7 +138,9 @@ export class Entity<
} }
getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] { getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
return this.getFields(include_virtual).filter((field) => field.isFillable(context)); return this.getFields({ virtual: include_virtual }).filter((field) =>
field.isFillable(context),
);
} }
getRequiredFields(): Field[] { getRequiredFields(): Field[] {
@@ -189,9 +192,24 @@ export class Entity<
return this.fields.findIndex((field) => field.name === name) !== -1; return this.fields.findIndex((field) => field.name === name) !== -1;
} }
getFields(include_virtual: boolean = false): Field[] { getFields(opts?: { virtual?: boolean; sorted?: boolean }): Field[] {
if (include_virtual) return this.fields; const fields = [...this.fields];
return this.fields.filter((f) => !f.isVirtual()); const sort = opts?.sorted !== false;
const fields_order = this.config.fields_order;
if (fields_order && fields_order.length > 0 && sort) {
if (fields_order.length !== this.fields.length) {
$console.warn("Fields order must contain all fields");
} else {
// sort fields by order
fields.sort((a, b) => {
const a_index = fields_order.indexOf(a.name);
const b_index = fields_order.indexOf(b.name);
return a_index - b_index;
});
}
}
if (opts?.virtual) return fields;
return fields.filter((f) => !f.isVirtual());
} }
addField(field: Field) { addField(field: Field) {
@@ -275,7 +293,7 @@ export class Entity<
fields = this.getFillableFields(options.context); fields = this.getFillableFields(options.context);
break; break;
default: default:
fields = this.getFields(true); fields = this.getFields({ virtual: true });
} }
const _fields = Object.fromEntries(fields.map((field) => [field.name, field])); const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
@@ -309,7 +327,12 @@ export class Entity<
toJSON() { toJSON() {
return { return {
type: this.type, type: this.type,
fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])), fields: Object.fromEntries(
this.getFields({ virtual: true, sorted: true }).map((field) => [
field.name,
field.toJSON(),
]),
),
config: this.config, config: this.config,
}; };
} }
+2 -2
View File
@@ -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",
+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 {
+1 -1
View File
@@ -76,7 +76,7 @@ export class SchemaManager {
} }
getIntrospectionFromEntity(entity: Entity): IntrospectedTable { getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
const fields = entity.getFields(false); const fields = entity.getFields({ virtual: false, sorted: true });
const indices = this.em.getIndicesOf(entity); const indices = this.em.getIndicesOf(entity);
// this is intentionally setting values to defaults, like "nullable" and "default" // this is intentionally setting values to defaults, like "nullable" and "default"
+3
View File
@@ -29,6 +29,7 @@ export {
type InitialModuleConfigs, type InitialModuleConfigs,
ModuleManagerEvents, ModuleManagerEvents,
} from "./modules/ModuleManager"; } from "./modules/ModuleManager";
export type * from "modules/ModuleApi";
export type { ServerEnv } from "modules/Controller"; export type { ServerEnv } from "modules/Controller";
export type { BkndConfig } from "bknd/adapter"; export type { BkndConfig } from "bknd/adapter";
@@ -115,6 +116,7 @@ export {
genericSqlite, genericSqlite,
genericSqliteUtils, genericSqliteUtils,
type GenericSqliteConnection, type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "data/connection/sqlite/GenericSqliteConnection"; } from "data/connection/sqlite/GenericSqliteConnection";
export { export {
EntityTypescript, EntityTypescript,
@@ -128,6 +130,7 @@ export type { EntityRelation } from "data/relations";
export type * from "data/entities/Entity"; export type * from "data/entities/Entity";
export type { EntityManager } from "data/entities/EntityManager"; export type { EntityManager } from "data/entities/EntityManager";
export type { SchemaManager } from "data/schema/SchemaManager"; export type { SchemaManager } from "data/schema/SchemaManager";
export type * from "data/entities";
export { export {
BaseIntrospector, BaseIntrospector,
Connection, Connection,
+2 -4
View File
@@ -181,16 +181,14 @@ export class MediaController extends Controller {
"param", "param",
s.object({ s.object({
entity: entitiesEnum, entity: entitiesEnum,
id: s.number(), id: s.anyOf([s.number(), s.string()]),
field: s.string(), field: s.string(),
}), }),
), ),
jsc("query", s.object({ overwrite: s.boolean().optional() })), jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
async (c) => { async (c) => {
const entity_name = c.req.param("entity"); const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
const field_name = c.req.param("field");
const entity_id = Number.parseInt(c.req.param("id"));
// check if entity exists // check if entity exists
const entity = this.media.em.entity(entity_name); const entity = this.media.em.entity(entity_name);
+1 -1
View File
@@ -9,6 +9,6 @@ export const mediaFields = {
etag: text(), etag: text(),
modified_at: datetime(), modified_at: datetime(),
reference: text(), reference: text(),
entity_id: number(), entity_id: text(),
metadata: json(), metadata: json(),
}; };
+1 -1
View File
@@ -103,7 +103,7 @@ export class Storage implements EmitsEvents {
...dim, ...dim,
}; };
} catch (e) { } catch (e) {
$console.warn("Failed to get image dimensions", e); $console.warn("Failed to get image dimensions", e, file);
} }
} }
@@ -1,12 +1,12 @@
import { hash, pickHeaders, s, parse } from "bknd/utils"; import { hash, pickHeaders, s, parse, secret } from "bknd/utils";
import type { FileBody, FileListObject, FileMeta } from "../../Storage"; import type { FileBody, FileListObject, FileMeta } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter"; import { StorageAdapter } from "../../StorageAdapter";
export const cloudinaryAdapterConfig = s.object( export const cloudinaryAdapterConfig = s.object(
{ {
cloud_name: s.string(), cloud_name: s.string(),
api_key: s.string(), api_key: secret(),
api_secret: s.string(), api_secret: secret(),
upload_preset: s.string().optional(), upload_preset: s.string().optional(),
}, },
{ title: "Cloudinary", description: "Cloudinary media storage" }, { title: "Cloudinary", description: "Cloudinary media storage" },
@@ -8,15 +8,15 @@ import type {
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { AwsClient } from "core/clients/aws/AwsClient"; import { AwsClient } from "core/clients/aws/AwsClient";
import { isDebug } from "core/env"; import { isDebug } from "core/env";
import { isFile, pickHeaders2, parse, s } from "bknd/utils"; import { isFile, pickHeaders2, parse, s, secret } from "bknd/utils";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
import type { FileBody, FileListObject } from "../../Storage"; import type { FileBody, FileListObject } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter"; import { StorageAdapter } from "../../StorageAdapter";
export const s3AdapterConfig = s.object( export const s3AdapterConfig = s.object(
{ {
access_key: s.string(), access_key: secret(),
secret_access_key: s.string(), secret_access_key: secret(),
url: s.string({ url: s.string({
pattern: "^https?://(?:.*)?[^/.]+$", pattern: "^https?://(?:.*)?[^/.]+$",
description: "URL to S3 compatible endpoint without trailing slash", description: "URL to S3 compatible endpoint without trailing slash",
+77 -423
View File
@@ -1,26 +1,21 @@
import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils"; import { objectEach, transformObject, McpServer, type s, SecretSchema, setPath } from "bknd/utils";
import { DebugLogger } from "core/utils/DebugLogger"; import { DebugLogger } from "core/utils/DebugLogger";
import { Guard } from "auth/authorize/Guard"; import { Guard } from "auth/authorize/Guard";
import { env } from "core/env"; import { env } from "core/env";
import { BkndError } from "core/errors";
import { EventManager, Event } from "core/events"; import { EventManager, Event } from "core/events";
import * as $diff from "core/object/diff";
import type { Connection } from "data/connection"; import type { Connection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager"; import { EntityManager } from "data/entities/EntityManager";
import * as proto from "data/prototype";
import { TransformPersistFailedException } from "data/errors";
import { Hono } from "hono"; import { Hono } from "hono";
import type { Kysely } from "kysely";
import { mergeWith } from "lodash-es";
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
import { AppServer } from "modules/server/AppServer";
import { AppAuth } from "../auth/AppAuth";
import { AppData } from "../data/AppData";
import { AppFlows } from "../flows/AppFlows";
import { AppMedia } from "../media/AppMedia";
import type { ServerEnv } from "./Controller"; import type { ServerEnv } from "./Controller";
import { Module, type ModuleBuildContext } from "./Module"; import { Module, type ModuleBuildContext } from "./Module";
import { ModuleHelper } from "./ModuleHelper"; import { ModuleHelper } from "./ModuleHelper";
import { AppServer } from "modules/server/AppServer";
import { AppAuth } from "auth/AppAuth";
import { AppData } from "data/AppData";
import { AppFlows } from "flows/AppFlows";
import { AppMedia } from "media/AppMedia";
import type { PartialRec } from "core/types";
import { mergeWith, pick } from "lodash-es";
export type { ModuleBuildContext }; export type { ModuleBuildContext };
@@ -47,13 +42,8 @@ export type ModuleSchemas = {
export type ModuleConfigs = { export type ModuleConfigs = {
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>; [K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
}; };
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type InitialModuleConfigs = export type InitialModuleConfigs = { version?: number } & PartialRec<ModuleConfigs>;
| ({
version: number;
} & ModuleConfigs)
| PartialRec<ModuleConfigs>;
enum Verbosity { enum Verbosity {
silent = 0, silent = 0,
@@ -80,42 +70,14 @@ export type ModuleManagerOptions = {
seed?: (ctx: ModuleBuildContext) => Promise<void>; seed?: (ctx: ModuleBuildContext) => Promise<void>;
// called right after modules are built, before finish // called right after modules are built, before finish
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>; onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
// whether to store secrets in the database
storeSecrets?: boolean;
// provided secrets
secrets?: Record<string, any>;
/** @deprecated */ /** @deprecated */
verbosity?: Verbosity; verbosity?: Verbosity;
}; };
export type ConfigTable<Json = ModuleConfigs> = {
id?: number;
version: number;
type: "config" | "diff" | "backup";
json: Json;
created_at?: Date;
updated_at?: Date;
};
const configJsonSchema = s.anyOf([
getDefaultSchema(),
s.array(
s.strictObject({
t: s.string({ enum: ["a", "r", "e"] }),
p: s.array(s.anyOf([s.string(), s.number()])),
o: s.any().optional(),
n: s.any().optional(),
}),
),
]);
export const __bknd = proto.entity(TABLE_NAME, {
version: proto.number().required(),
type: proto.enumm({ enum: ["config", "diff", "backup"] }).required(),
json: proto.jsonSchema({ schema: configJsonSchema.toJSON() }).required(),
created_at: proto.datetime(),
updated_at: proto.datetime(),
});
type ConfigTable2 = proto.Schema<typeof __bknd>;
interface T_INTERNAL_EM {
__bknd: ConfigTable2;
}
const debug_modules = env("modules_debug"); const debug_modules = env("modules_debug");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {} abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
@@ -127,8 +89,14 @@ export class ModuleManagerConfigUpdateEvent<
}> { }> {
static override slug = "mm-config-update"; static override slug = "mm-config-update";
} }
export class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{
secrets: Record<string, any>;
}> {
static override slug = "mm-secrets-extracted";
}
export const ModuleManagerEvents = { export const ModuleManagerEvents = {
ModuleManagerConfigUpdateEvent, ModuleManagerConfigUpdateEvent,
ModuleManagerSecretsExtractedEvent,
}; };
// @todo: cleanup old diffs on upgrade // @todo: cleanup old diffs on upgrade
@@ -137,8 +105,6 @@ export class ModuleManager {
static Events = ModuleManagerEvents; static Events = ModuleManagerEvents;
protected modules: Modules; protected modules: Modules;
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules // ctx for modules
em!: EntityManager; em!: EntityManager;
server!: Hono<ServerEnv>; server!: Hono<ServerEnv>;
@@ -146,42 +112,24 @@ export class ModuleManager {
guard!: Guard; guard!: Guard;
mcp!: ModuleBuildContext["mcp"]; mcp!: ModuleBuildContext["mcp"];
private _version: number = 0; protected _built = false;
private _built = false;
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
private logger: DebugLogger; protected logger: DebugLogger;
constructor( constructor(
private readonly connection: Connection, protected readonly connection: Connection,
private options?: Partial<ModuleManagerOptions>, protected options?: Partial<ModuleManagerOptions>,
) { ) {
this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules; this.modules = {} as Modules;
this.emgr = new EventManager({ ...ModuleManagerEvents }); this.emgr = new EventManager({ ...ModuleManagerEvents });
this.logger = new DebugLogger(debug_modules); this.logger = new DebugLogger(debug_modules);
let initial = {} as Partial<ModuleConfigs>;
if (options?.initial) { this.createModules(options?.initial ?? {});
if ("version" in options.initial) {
const { version, ...initialConfig } = options.initial;
this._version = version;
initial = stripMark(initialConfig);
this._booted_with = "provided";
} else {
initial = mergeWith(getDefaultConfig(), options.initial);
this._booted_with = "partial";
}
} }
this.logger.log("booted with", this._booted_with); protected onModuleConfigUpdated(key: string, config: any) {}
this.createModules(initial); private createModules(initial: PartialRec<ModuleConfigs>) {
}
private createModules(initial: Partial<ModuleConfigs>) {
this.logger.context("createModules").log("creating modules"); this.logger.context("createModules").log("creating modules");
try { try {
const context = this.ctx(true); const context = this.ctx(true);
@@ -211,46 +159,7 @@ export class ModuleManager {
return this._built; return this._built;
} }
/** protected rebuildServer() {
* This is set through module's setListener
* It's called everytime a module's config is updated in SchemaObject
* Needs to rebuild modules and save to database
*/
private async onModuleConfigUpdated(key: string, config: any) {
if (this.options?.onUpdated) {
await this.options.onUpdated(key as any, config);
} else {
await this.buildModules();
}
}
private repo() {
return this.__em.repo(__bknd, {
// to prevent exceptions when table doesn't exist
silent: true,
// disable counts for performance and compatibility
includeCounts: false,
});
}
private mutator() {
return this.__em.mutator(__bknd);
}
private get db() {
// @todo: check why this is neccessary
return this.connection.kysely as unknown as Kysely<{ table: ConfigTable }>;
}
// @todo: add indices for: version, type
async syncConfigTable() {
this.logger.context("sync").log("start");
const result = await this.__em.schema().sync({ force: true });
this.logger.log("done").clear();
return result;
}
private rebuildServer() {
this.server = new Hono<ServerEnv>(); this.server = new Hono<ServerEnv>();
if (this.options?.basePath) { if (this.options?.basePath) {
this.server = this.server.basePath(this.options.basePath); this.server = this.server.basePath(this.options.basePath);
@@ -299,252 +208,81 @@ export class ModuleManager {
}; };
} }
private async fetch(): Promise<ConfigTable | undefined> { extractSecrets() {
this.logger.context("fetch").log("fetching"); const moduleConfigs = structuredClone(this.configs());
const startTime = performance.now(); const secrets = { ...this.options?.secrets };
const extractedKeys: string[] = [];
// disabling console log, because the table might not exist yet for (const [key, module] of Object.entries(this.modules)) {
const { data: result } = await this.repo().findOne( const config = moduleConfigs[key];
{ type: "config" }, const schema = module.getSchema();
{
sort: { by: "version", dir: "desc" }, const extracted = [...schema.walk({ data: config })].filter(
}, (n) => n.schema instanceof SecretSchema,
); );
if (!result) { for (const n of extracted) {
this.logger.log("error fetching").clear(); const path = [key, ...n.instancePath].join(".");
return undefined;
}
this.logger if (typeof n.data === "string") {
.log("took", performance.now() - startTime, "ms", { extractedKeys.push(path);
version: result.version, secrets[path] = n.data;
id: result.id, setPath(moduleConfigs, path, "");
})
.clear();
return result as unknown as ConfigTable;
}
async save() {
this.logger.context("save").log("saving version", this.version());
const configs = this.configs();
const version = this.version();
try {
const state = await this.fetch();
if (!state) throw new BkndError("no config found");
this.logger.log("fetched version", state.version);
if (state.version !== version) {
// @todo: mark all others as "backup"
this.logger.log("version conflict, storing new version", state.version, version);
await this.mutator().insertOne({
version: state.version,
type: "backup",
json: configs,
});
await this.mutator().insertOne({
version: version,
type: "config",
json: configs,
});
} else {
this.logger.log("version matches", state.version);
// clean configs because of Diff() function
const diffs = $diff.diff(state.json, $diff.clone(configs));
this.logger.log("checking diff", [diffs.length]);
if (diffs.length > 0) {
// validate diffs, it'll throw on invalid
this.validateDiffs(diffs);
const date = new Date();
// store diff
await this.mutator().insertOne({
version,
type: "diff",
json: $diff.clone(diffs),
created_at: date,
updated_at: date,
});
// store new version
await this.mutator().updateWhere(
{
version,
json: configs,
updated_at: date,
} as any,
{
type: "config",
version,
},
);
} else {
this.logger.log("no diff, not saving");
}
}
} catch (e) {
if (e instanceof BkndError && e.message === "no config found") {
this.logger.log("no config, just save fresh");
// no config, just save
await this.mutator().insertOne({
type: "config",
version,
json: configs,
created_at: new Date(),
updated_at: new Date(),
});
} else if (e instanceof TransformPersistFailedException) {
$console.error("ModuleManager: Cannot save invalid config");
this.revertModules();
throw e;
} else {
$console.error("ModuleManager: Aborting");
this.revertModules();
throw e;
}
}
// re-apply configs to all modules (important for system entities)
this.setConfigs(configs);
// @todo: cleanup old versions?
this.logger.clear();
return this;
}
private revertModules() {
if (this._stable_configs) {
$console.warn("ModuleManager: Reverting modules");
this.setConfigs(this._stable_configs as any);
} else {
$console.error("ModuleManager: No stable configs to revert to");
}
}
/**
* Validates received diffs for an additional security control.
* Checks:
* - check if module is registered
* - run modules onBeforeUpdate() for added protection
*
* **Important**: Throw `Error` so it won't get catched.
*
* @param diffs
* @private
*/
private validateDiffs(diffs: $diff.DiffEntry[]): void {
// check top level paths, and only allow a single module to be modified in a single transaction
const modules = [...new Set(diffs.map((d) => d.p[0]))];
if (modules.length === 0) {
return;
}
for (const moduleName of modules) {
const name = moduleName as ModuleKey;
const module = this.get(name) as Module;
if (!module) {
const msg = "validateDiffs: module not registered";
// biome-ignore format: ...
$console.error(msg, JSON.stringify({ module: name, diffs }, null, 2));
throw new Error(msg);
}
// pass diffs to the module to allow it to throw
if (this._stable_configs?.[name]) {
const current = $diff.clone(this._stable_configs?.[name]);
const modified = $diff.apply({ [name]: current }, diffs)[name];
module.onBeforeUpdate(current, modified);
} }
} }
} }
private setConfigs(configs: ModuleConfigs): void { return {
configs: moduleConfigs,
secrets: pick(secrets, extractedKeys),
extractedKeys,
};
}
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
this.logger.log("setting configs"); this.logger.log("setting configs");
objectEach(configs, (config, key) => { for await (const [key, config] of Object.entries(configs)) {
if (!(key in this.modules)) continue;
try { try {
// setting "noEmit" to true, to not force listeners to update // setting "noEmit" to true, to not force listeners to update
this.modules[key].schema().set(config as any, true); const result = await this.modules[key].schema().set(config as any, true);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new Error( throw new Error(
`Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`, `Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`,
); );
} }
}); }
} }
async build(opts?: { fetch?: boolean }) { async build(opts?: any) {
this.logger.context("build").log("version", this.version()); this.createModules(this.options?.initial ?? {});
await this.ctx().connection.init();
// if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) {
if (opts?.fetch) {
this.logger.log("force fetch");
}
const result = await this.fetch();
// if no version, and nothing found, go with initial
if (!result) {
this.logger.log("nothing in database, go initial");
await this.setupInitial();
} else {
this.logger.log("db has", result.version);
// set version and config from fetched
this._version = result.version;
if (this.options?.trustFetched === true) {
this.logger.log("trusting fetched config (mark)");
mark(result.json);
}
// if version doesn't match, migrate before building
if (this.version() !== CURRENT_VERSION) {
this.logger.log("now migrating");
await this.syncConfigTable();
const version_before = this.version();
const [_version, _configs] = await migrate(version_before, result.json, {
db: this.db,
});
this._version = _version;
this.ctx().flags.sync_required = true;
this.logger.log("migrated to", _version);
$console.log("Migrated config from", version_before, "to", this.version());
this.createModules(_configs);
await this.buildModules();
} else {
this.logger.log("version is current", this.version());
this.createModules(result.json);
await this.buildModules(); await this.buildModules();
// if secrets were provided, extract, merge and build again
const provided_secrets = this.options?.secrets ?? {};
if (Object.keys(provided_secrets).length > 0) {
const { configs, extractedKeys } = this.extractSecrets();
for (const key of extractedKeys) {
if (key in provided_secrets) {
setPath(configs, key, provided_secrets[key]);
} }
} }
} else {
if (this.version() !== CURRENT_VERSION) { await this.setConfigs(configs);
throw new Error(
`Given version (${this.version()}) and current version (${CURRENT_VERSION}) do not match.`,
);
}
this.logger.log("current version is up to date", this.version());
await this.buildModules(); await this.buildModules();
} }
this.logger.log("done");
this.logger.clear();
return this; return this;
} }
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) { protected async buildModules(options?: {
graceful?: boolean;
ignoreFlags?: boolean;
drop?: boolean;
}) {
const state = { const state = {
built: false, built: false,
modules: [] as ModuleKey[], modules: [] as ModuleKey[],
@@ -580,12 +318,8 @@ export class ModuleManager {
this.logger.log("db sync requested"); this.logger.log("db sync requested");
// sync db // sync db
await ctx.em.schema().sync({ force: true }); await ctx.em.schema().sync({ force: true, drop: options?.drop });
state.synced = true; state.synced = true;
// save
await this.save();
state.saved = true;
} }
if (ctx.flags.ctx_reload_required) { if (ctx.flags.ctx_reload_required) {
@@ -601,92 +335,12 @@ export class ModuleManager {
ctx.flags = Module.ctx_flags; ctx.flags = Module.ctx_flags;
// storing last stable config version // storing last stable config version
this._stable_configs = $diff.clone(this.configs()); //this._stable_configs = $diff.clone(this.configs());
this.logger.clear(); this.logger.clear();
return state; return state;
} }
protected async setupInitial() {
this.logger.context("initial").log("start");
this._version = CURRENT_VERSION;
await this.syncConfigTable();
const state = await this.buildModules();
if (!state.saved) {
await this.save();
}
const ctx = {
...this.ctx(),
// disable events for initial setup
em: this.ctx().em.fork(),
};
// perform a sync
await ctx.em.schema().sync({ force: true });
await this.options?.seed?.(ctx);
// run first boot event
await this.options?.onFirstBoot?.();
this.logger.clear();
}
mutateConfigSafe<Module extends keyof Modules>(
name: Module,
): Pick<ReturnType<Modules[Module]["schema"]>, "set" | "patch" | "overwrite" | "remove"> {
const module = this.modules[name];
return new Proxy(module.schema(), {
get: (target, prop: string) => {
if (!["set", "patch", "overwrite", "remove"].includes(prop)) {
throw new Error(`Method ${prop} is not allowed`);
}
return async (...args) => {
$console.log("[Safe Mutate]", name);
try {
// overwrite listener to run build inside this try/catch
module.setListener(async () => {
await this.emgr.emit(
new ModuleManagerConfigUpdateEvent({
ctx: this.ctx(),
module: name,
config: module.config as any,
}),
);
await this.buildModules();
});
const result = await target[prop](...args);
// revert to original listener
module.setListener(async (c) => {
await this.onModuleConfigUpdated(name, c);
});
// if there was an onUpdated listener, call it after success
// e.g. App uses it to register module routes
if (this.options?.onUpdated) {
await this.options.onUpdated(name, module.config as any);
}
return result;
} catch (e) {
$console.error(`[Safe Mutate] failed "${name}":`, e);
// revert to previous config & rebuild using original listener
this.revertModules();
await this.onModuleConfigUpdated(name, module.config as any);
$console.warn(`[Safe Mutate] reverted "${name}":`);
// make sure to throw the error
throw e;
}
};
},
});
}
get<K extends keyof Modules>(key: K): Modules[K] { get<K extends keyof Modules>(key: K): Modules[K] {
if (!(key in this.modules)) { if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`); throw new Error(`Module "${key}" doesn't exist, cannot get`);
@@ -695,7 +349,7 @@ export class ModuleManager {
} }
version() { version() {
return this._version; return 0;
} }
built() { built() {

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