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 { getDummyConnection } from "./helper";
import { Hono } from "hono";
import * as proto from "../src/data/prototype";
import { pick } from "lodash-es";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("App tests", async () => {
test("boots and pongs", async () => {
@@ -19,7 +22,7 @@ describe("App tests", async () => {
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
initialConfig: {
config: {
auth: {
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 { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
@@ -19,50 +19,39 @@ describe("adapter", () => {
expect(
omitKeys(
await adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
initialConfig: { server: { cors: { origin: "test" } } },
config: { server: { cors: { origin: "test" } } },
});
});
/* it.only("...", async () => {
const app = await adapter.createAdapterApp();
}); */
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
it("allows all properties in app function", async () => {
const called = mock(() => null);
const config = await adapter.makeConfig(
{
initialConfig: { server: { cors: { origin: "random" } } },
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: {
mode: "db",
},
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
undefined,
{ id },
{ foo: "bar" },
);
const second = await adapter.createAdapterApp();
const third = await adapter.createAdapterApp(undefined, undefined, { id });
await first.build();
await second.build();
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("*");
expect(config.connection).toEqual({ url: "test" });
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(config.options).toEqual({ mode: "db" });
await config.onBuilt?.(null as any);
expect(called).toHaveBeenCalled();
});
adapterTestSuite(bunTestRunner, {
-1
View File
@@ -42,7 +42,6 @@ describe("Api", async () => {
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
console.log(params);
expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie");
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 { 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", () => {
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 () => {
const called = mock(() => null);
await createApp({
@@ -29,7 +43,7 @@ describe("App", () => {
expect(called).toHaveBeenCalled();
const app = createApp({
initialConfig: {
config: {
data: proto
.em({
todos: proto.entity("todos", {
@@ -139,7 +153,7 @@ describe("App", () => {
test("getMcpClient", async () => {
const app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
+1 -1
View File
@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
let server: McpServer;
beforeEach(async () => {
app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
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 { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("mcp", () => {
it("should have tools", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},
+1 -1
View File
@@ -41,7 +41,7 @@ describe("mcp data", async () => {
beforeEach(async () => {
const time = performance.now();
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
+1 -1
View File
@@ -21,7 +21,7 @@ describe("mcp media", async () => {
beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter);
app = createApp({
initialConfig: {
config: {
media: {
enabled: true,
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 { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
/**
* - [x] config_server_get
@@ -11,7 +15,7 @@ describe("mcp system", async () => {
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
+1 -1
View File
@@ -14,7 +14,7 @@ describe("mcp system", async () => {
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
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 { createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("repros", async () => {
/**
@@ -88,7 +92,7 @@ describe("repros", async () => {
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();
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 { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
+4 -4
View File
@@ -248,7 +248,7 @@ describe("Core Utils", async () => {
expect(utils.getContentName(request)).toBe(name);
});
test.only("detectImageDimensions", async () => {
test("detectImageDimensions", async () => {
// wrong
// @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
@@ -267,12 +267,12 @@ describe("Core Utils", async () => {
});
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");
console.log(utils.datetimeStringUTC(new Date()));
/*console.log(utils.datetimeStringUTC(new Date()));
console.log(utils.datetimeStringUTC());
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 { 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 { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
import { Entity, EntityManager, type EntityData } from "data/entities";
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
import { ManyToOneRelation } from "data/relations";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"]));
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
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 { ManyToOneRelation } from "data/relations";
import { TextField } from "data/fields";
import { JoinBuilder } from "data/entities/query/JoinBuilder";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] JoinBuilder", async () => {
test("missing relation", async () => {
+3 -2
View File
@@ -9,13 +9,14 @@ import {
} from "data/relations";
import { NumberField, TextField } from "data/fields";
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";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"]));
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
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 { TextField } from "data/fields";
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 { RepositoryEvents } from "data/events";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
async function sleep(ms: number) {
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 { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
import { TextField } from "data/fields";
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
import { WithBuilder } from "data/entities/query/WithBuilder";
import { schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
const { dummyConnection } = getDummyConnection();
@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
expect(index.name).toEqual("idx_test_name");
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,
EntityRelation,
EntityRelationAnchor,
ManyToManyRelation,
RelationTypes,
} from "data/relations";
import * as proto from "data/prototype";
class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) {
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
const relation2 = new TestEntityRelation({ required: 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" };
}
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>) {
const { sql, parameters } = qb.compile();
return { sql, parameters };
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
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> {
@@ -1,12 +1,9 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -66,9 +63,10 @@ const configs = {
};
function createAuthApp() {
const { dummyConnection } = getDummyConnection();
const app = createApp({
connection: dummyConnection,
initialConfig: {
config: {
auth: configs.auth,
},
});
@@ -151,8 +149,8 @@ describe("integration auth", () => {
const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email);
expect(users[0]?.email).toBe(configs.users.normal.email);
expect(users[1]?.email).toBe(configs.users.admin.email);
});
it("should log you in with API", async () => {
@@ -223,7 +221,7 @@ describe("integration auth", () => {
app.server.get("/get", auth(), async (c) => {
return c.json({
user: c.get("auth").user ?? null,
user: c.get("auth")?.user ?? null,
});
});
app.server.get("/wait", auth(), async (c) => {
@@ -242,7 +240,7 @@ describe("integration auth", () => {
{
await new Promise((r) => setTimeout(r, 10));
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(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 { Api } from "../../src/Api";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("integration config", () => {
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 type { TAppMediaConfig } from "../../src/media/media-schema";
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(() => {
disableConsoleLog();
registries.media.register("local", StorageLocalAdapter);
});
afterAll(enableConsoleLog);
const path = `${assetsPath}/image.png`;
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({
initialConfig: {
config: {
media: mergeObject(
{
enabled: true,
@@ -40,9 +43,6 @@ function makeName(ext: string) {
return randomString(10) + "." + ext;
}
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test("accepts direct", async () => {
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 { em, entity, make, text } from "data/prototype";
import { AppAuth, type ModuleBuildContext } from "modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppAuth", () => {
test.only("...", () => {
test.skip("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
@@ -147,7 +150,7 @@ describe("AppAuth", () => {
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
jwt: {
@@ -177,7 +180,7 @@ describe("AppAuth", () => {
test("should allow additional user fields", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
entity_name: "users",
enabled: true,
@@ -201,7 +204,7 @@ describe("AppAuth", () => {
test("ensure user field configs is always correct", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},
+2 -2
View File
@@ -7,7 +7,7 @@ import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite";
describe("AppMedia", () => {
test.only("...", () => {
test.skip("...", () => {
const media = new AppMedia();
console.log(media.toJSON());
});
@@ -18,7 +18,7 @@ describe("AppMedia", () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
config: {
media: {
entity_name: "media",
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 { disableConsoleLog, enableConsoleLog } from "core/utils";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDefaultConfig } from "modules/ModuleManager";
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
import { getDummyConnection } from "../helper";
import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection();
@@ -133,7 +138,7 @@ describe("ModuleManager", async () => {
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json },
initial: { version: version - 1, ...json } as any,
});
await mm2.syncConfigTable();
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 Kysely, sql } from "kysely";
@@ -6,6 +6,10 @@ import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json";
import v8 from "./samples/v8.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
async function createVersionedApp(config: InitialModuleConfigs | any) {
+9 -4
View File
@@ -3,20 +3,25 @@ import c from "picocolors";
import { formatNumber } from "bknd/utils";
import * as esbuild from "esbuild";
const deps = Object.keys(pkg.dependencies);
const external = ["jsonv-ts/*", "wrangler", ...deps];
if (process.env.DEBUG) {
await esbuild.build({
const result = await esbuild.build({
entryPoints: ["./src/cli/index.ts"],
outdir: "./dist/cli",
platform: "node",
minify: false,
minify: true,
format: "esm",
metafile: true,
bundle: true,
external: ["jsonv-ts", "jsonv-ts/*"],
external,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
await Bun.write("./dist/cli/metafile-esm.json", JSON.stringify(result.metafile, null, 2));
process.exit(0);
}
@@ -26,7 +31,7 @@ const result = await Bun.build({
outdir: "./dist/cli",
env: "PUBLIC_*",
minify: true,
external: ["jsonv-ts", "jsonv-ts/*"],
external,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
+2
View File
@@ -252,6 +252,8 @@ async function buildAdapters() {
platform: "neutral",
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}),
// specific adatpers
+1 -1
View File
@@ -3,7 +3,7 @@ import { createApp } from "bknd/adapter/bun";
async function generate() {
console.info("Generating MCP documentation...");
const app = await createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
+7 -5
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"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.",
"homepage": "https://bknd.io",
"repository": {
@@ -30,7 +30,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"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",
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
@@ -78,10 +78,10 @@
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@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",
"@dagrejs/dagre": "^1.1.4",
"@hono/vite-dev-server": "^0.19.1",
"@hono/vite-dev-server": "^0.21.0",
"@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.9",
"@mantine/modals": "^7.17.1",
@@ -130,7 +130,9 @@
"vite-plugin-circular-dependency": "^0.5.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0"
"wouter": "^3.6.0",
"wrangler": "^4.37.1",
"miniflare": "^4.20250913.0"
},
"optionalDependencies": {
"@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 type { Hono } from "hono";
import {
ModuleManager,
type InitialModuleConfigs,
type ModuleBuildContext,
type ModuleConfigs,
type ModuleManagerOptions,
type Modules,
ModuleManager,
type ModuleBuildContext,
type ModuleManagerOptions,
} from "modules/ModuleManager";
import { DbModuleManager } from "modules/db/DbModuleManager";
import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
import type { MaybePromise } from "core/types";
import type { MaybePromise, PartialRec } from "core/types";
import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers";
@@ -93,20 +94,23 @@ export type AppOptions = {
email?: IEmailDriver;
cache?: ICacheDriver;
};
mode?: "db" | "code";
readonly?: boolean;
};
export type CreateAppConfig = {
/**
* bla
*/
connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs;
config?: PartialRec<ModuleConfigs>;
options?: AppOptions;
};
export type AppConfig = InitialModuleConfigs;
export type AppConfig = { version: number } & ModuleConfigs;
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;
modules: ModuleManager;
@@ -121,8 +125,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
constructor(
public connection: C,
_initialConfig?: InitialModuleConfigs,
private options?: Options,
_config?: Config,
public options?: Options,
) {
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.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 ?? {}),
initial: _initialConfig,
initial: _config,
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.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);
}
get mode() {
return this.options?.mode ?? "db";
}
isReadOnly() {
return Boolean(this.mode === "code" || this.options?.readonly);
}
get emgr() {
return this.modules.ctx().emgr;
}
@@ -175,7 +191,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
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
if (this._building) {
while (this._building) {
@@ -188,7 +204,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = 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();
@@ -215,10 +231,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = false;
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.mutateConfigSafe(module);
}
get server() {
return this.modules.server;
}
@@ -377,5 +389,5 @@ export function createApp(config: CreateAppConfig = {}) {
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 { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
import type { BkndConfig, DefaultArgs } from "./index";
import type { App } from "App";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
export function adapterTestSuite<
Config extends BkndConfig = BkndConfig,
@@ -13,24 +14,17 @@ export function adapterTestSuite<
label = "app",
overrides = {},
}: {
makeApp: (
config: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
makeApp: (config: Config, args?: Args) => Promise<App>;
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
label?: string;
overrides?: {
dbUrl?: string;
};
},
) {
const { test, expect, mock } = testRunner;
const id = crypto.randomUUID();
const { test, expect, mock, beforeAll, afterAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any;
@@ -39,7 +33,7 @@ export function adapterTestSuite<
const config = {
app: (env) => ({
connection: { url: env.url },
initialConfig: {
config: {
server: { cors: { origin: env.origin } },
},
}),
@@ -53,11 +47,10 @@ export function adapterTestSuite<
url: overrides.dbUrl ?? ":memory:",
origin: "localhost",
} as any,
{ force: false, id },
);
expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost");
expect(beforeBuild).toHaveBeenCalledTimes(1);
expect(beforeBuild).toHaveBeenCalledTimes(2);
expect(onBuilt).toHaveBeenCalledTimes(1);
});
@@ -68,8 +61,8 @@ export function adapterTestSuite<
return { res, data };
};
test("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { force: false, id });
/* test.skip("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
@@ -77,14 +70,14 @@ export function adapterTestSuite<
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
const fetcher = makeHandler(undefined, undefined, { id, force: true });
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*");
});
}); */
}
}
+1 -1
View File
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, {
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 TAstro = {
@@ -9,17 +9,12 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<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>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
export function serve<Env = AstroEnv>(config: AstroBkndConfig<Env> = {}, args: Env = {} as Env) {
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 { handle } from "hono/aws-lambda";
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;
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>(
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
): Promise<App> {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
...additional,
},
args ?? process.env,
opts,
);
}
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (event) => {
const app = await createApp(config, args, opts);
const app = await createApp(config, args);
return await handle(app.server)(event);
};
}
+2 -2
View File
@@ -11,8 +11,8 @@ describe("aws adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: awsLambda.createApp,
// @todo: add a request to lambda event translator?
makeHandler: (c, a, o) => async (request: Request) => {
const app = await awsLambda.createApp(c, a, o);
makeHandler: (c, a) => async (request: Request) => {
const app = await awsLambda.createApp(c, a);
return app.fetch(request);
},
});
+4 -9
View File
@@ -1,7 +1,7 @@
/// <reference types="bun-types" />
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 { config, type App } from "bknd";
import type { ServeOptions } from "bun";
@@ -13,7 +13,6 @@ export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOpt
export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter();
@@ -28,19 +27,17 @@ export async function createApp<Env = BunEnv>(
...config,
},
args ?? (process.env as Env),
opts,
);
}
export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args ?? (process.env as Env));
}
return app.fetch(req);
};
@@ -50,7 +47,7 @@ export function serve<Env = BunEnv>(
{
distPath,
connection,
initialConfig,
config: _config,
options,
port = config.server.default_port,
onBuilt,
@@ -60,7 +57,6 @@ export function serve<Env = BunEnv>(
...serveOptions
}: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
Bun.serve({
...serveOptions,
@@ -68,7 +64,7 @@ export function serve<Env = BunEnv>(
fetch: createHandler(
{
connection,
initialConfig,
config: _config,
options,
onBuilt,
buildConfig,
@@ -77,7 +73,6 @@ export function serve<Env = BunEnv>(
serveStatic,
},
args,
opts,
),
});
@@ -1,8 +1,9 @@
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection";
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 { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, {
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
}),
rawDialectDetails: [],
});
test("onCreateConnection", async () => {
const called = mock(() => null);
const conn = bunSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(Database);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(Database);
expect(called).toHaveBeenCalledTimes(1);
});
});
@@ -1,40 +1,53 @@
import { Database } from "bun:sqlite";
import { 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 BunSqliteConnectionConfig = {
database: Database;
};
export type BunSqliteConnectionConfig = Omit<
GenericSqliteConnectionConfig<Database>,
"name" | "supports"
> &
({ database?: Database; url?: never } | { url?: string; database?: never });
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
let db: Database;
export function bunSqlite(config?: BunSqliteConnectionConfig) {
let db: Database | undefined;
if (config) {
if ("database" in config) {
if ("database" in config && config.database) {
db = config.database;
} else {
} else if (config.url) {
db = new Database(config.url);
}
} else {
}
if (!db) {
db = new Database(":memory:");
}
return genericSqlite("bun-sqlite", db, (utils) => {
//const fn = cache ? "query" : "prepare";
const getStmt = (sql: string) => db.prepare(sql);
return genericSqlite(
"bun-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => db.prepare(sql);
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
});
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
},
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 = {
describe,
@@ -8,4 +8,5 @@ export const bunTestRunner = {
beforeEach,
afterEach,
afterAll,
beforeAll,
};
@@ -5,8 +5,8 @@ import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/* beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); */
describe("cf adapter", () => {
const DB_URL = ":memory:";
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
const staticConfig = await makeConfig(
{
connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } },
config: { data: { basepath: 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();
const dynamicConfig = await makeConfig(
{
app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } },
config: { data: { basepath: env.DB_URL } },
connection: { url: env.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();
});
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await createApp(c, { env: a } as any, o);
makeApp: async (c, a) => {
return await createApp(c, { env: a } as any);
},
makeHandler: (c, a, o) => {
makeHandler: (c, a) => {
console.log("args", a);
return async (request: any) => {
const app = await createApp(
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
connection: { url: DB_URL },
},
a as any,
o,
);
return app.fetch(request);
};
@@ -5,7 +5,7 @@ import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import type { MaybePromise } from "bknd";
import { $console } from "bknd/utils";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
declare global {
@@ -34,12 +34,8 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
};
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
config: CloudflareBkndConfig<Env> = {},
ctx: Partial<CloudflareContext<Env>> = {},
opts: RuntimeOptions = {
// by default, require the app to be rebuilt every time
force: true,
},
) {
const appConfig = await makeConfig(
{
@@ -53,7 +49,7 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
},
ctx,
);
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts);
return await createRuntimeApp<Env>(appConfig, ctx?.env);
}
// compatiblity
@@ -1,11 +1,12 @@
/// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const script = `
import { DurableObject } from "cloudflare:workers";
@@ -40,6 +41,9 @@ export default {
}
`;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
import { viTestRunner } from "adapter/node/vitest";
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
import { Miniflare } from "miniflare";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("cacheWorkersKV", async () => {
beforeAll(() => {
+19 -7
View File
@@ -5,8 +5,9 @@ import {
type CloudflareBkndConfig,
type CloudflareEnv,
} from "bknd/adapter/cloudflare";
import type { PlatformProxy } from "wrangler";
import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
import process from "node:process";
import { $console } from "bknd/utils";
export type WithPlatformProxyOptions = {
/**
@@ -14,22 +15,27 @@ export type WithPlatformProxyOptions = {
* You can override/force this by setting this option.
*/
useProxy?: boolean;
proxyOptions?: GetPlatformProxyOptions;
};
export function withPlatformProxy<Env extends CloudflareEnv>(
config?: CloudflareBkndConfig<Env>,
config: CloudflareBkndConfig<Env> = {},
opts?: WithPlatformProxyOptions,
) {
const use_proxy =
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
let proxy: PlatformProxy | undefined;
$console.log("Using cloudflare platform proxy");
async function getEnv(env?: Env): Promise<Env> {
if (use_proxy) {
if (!proxy) {
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
proxy = await getPlatformProxy();
setTimeout(proxy?.dispose, 1000);
proxy = await getPlatformProxy(opts?.proxyOptions);
process.on("exit", () => {
proxy?.dispose();
});
}
return proxy.env as unknown as Env;
}
@@ -50,16 +56,22 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
// @ts-ignore
app: async (_env) => {
const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
if (config?.app === undefined && use_proxy) {
const binding = getBinding(env, "D1Database");
if (config?.app === undefined && use_proxy && binding) {
return {
connection: d1Sqlite({
binding: binding.value,
}),
};
} 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 || {};
},
@@ -3,8 +3,12 @@ import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import path from "node:path";
import { describe, afterAll, test, expect } from "vitest";
import { describe, afterAll, test, expect, beforeAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
let mf: Miniflare | undefined;
describe("StorageR2Adapter", async () => {
+172 -35
View File
@@ -24,45 +24,157 @@ export function devFsVitePlugin({
projectRoot = config.root;
},
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
const originalStdoutWrite = process.stdout.write;
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
const output = chunk.toString();
// Check if this output contains our special write request
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) {
try {
// Extract the JSON from the log line
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/);
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");
}
// Skip our own debug output
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
// @ts-ignore
// biome-ignore lint/style/noArguments: <explanation>
return originalStdoutWrite.apply(process.stdout, arguments);
}
// Process the write request immediately
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
await nodeWriteFile(fullPath, writeRequest.data);
if (verbose) {
console.debug("[dev-fs-plugin] File written successfully!");
}
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Track if we process any protocol messages (to suppress output)
let processedProtocolMessage = false;
// Don't output the raw write request to console
return true;
// 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})`,
);
}
}
} catch (error) {
// Not a valid write request, continue with normal output
}
processedProtocolMessage = true;
}
// Process all end markers in this output
if (output.includes("{{DEV_FS_END}}")) {
const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)];
for (const endMatch of endMatches) {
const requestId = endMatch[1];
const request = activeRequests.get(requestId);
if (request && request.receivedChunks === request.totalChunks) {
try {
// Reconstruct the base64 string
const fullBase64 = request.chunks.join("");
verbose &&
console.debug(
`[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`,
);
// Decode and parse
const decodedJson = atob(fullBase64);
const writeRequest = JSON.parse(decodedJson);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
verbose &&
console.debug(
`[dev-fs-plugin] Processing write request for ${writeRequest.filename}`,
);
// Process the write request
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
verbose &&
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
await nodeWriteFile(fullPath, writeRequest.data);
verbose &&
console.debug("[dev-fs-plugin] File written successfully!");
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Clean up
activeRequests.delete(requestId);
return true;
}
} catch (error) {
console.error(
"[dev-fs-plugin] Error processing chunked request:",
String(error),
);
activeRequests.delete(requestId);
}
} else if (request) {
verbose &&
console.debug(
`[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`,
);
}
}
processedProtocolMessage = true;
}
// If we processed any protocol messages, suppress output
if (processedProtocolMessage) {
return callback ? callback() : true;
}
// @ts-ignore
@@ -78,7 +190,10 @@ export function devFsVitePlugin({
// @ts-ignore
transform(code, id, options) {
// 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
if (id.includes(configFile)) {
@@ -92,7 +207,7 @@ export function devFsVitePlugin({
if (typeof globalThis !== 'undefined') {
globalThis.__devFsPolyfill = {
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
// The main process will watch for this specific log pattern
@@ -103,16 +218,38 @@ if (typeof globalThis !== 'undefined') {
timestamp: Date.now()
};
// Output as a specially formatted console message
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest));
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""}
// Output as a specially formatted console message with end delimiter
// Base64 encode the JSON to avoid any control character issues
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 polyfill + code;
} else {
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
}
},
} satisfies Plugin;
+19 -38
View File
@@ -13,21 +13,14 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite";
export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>);
onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>;
app?: Omit<BkndConfig, "app"> | ((args: Args) => MaybePromise<Omit<BkndConfig<Args>, "app">>);
onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0];
};
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> & {
distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
@@ -41,7 +34,7 @@ export type DefaultArgs = {
export async function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>,
args?: Args,
): Promise<CreateAppConfig> {
): Promise<Omit<BkndConfig<Args>, "app">> {
let additionalConfig: CreateAppConfig = {};
const { app, ...rest } = config;
if (app) {
@@ -59,45 +52,34 @@ export async function makeConfig<Args = DefaultArgs>(
}
// a map that contains all apps by id
const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config,
args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> {
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
await config.beforeBuild?.(undefined, $registries);
app = App.create(appConfig);
if (!opts?.force) {
apps.set(id, app);
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
return app;
return App.create(appConfig);
}
export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {},
args?: Args,
opts?: FrameworkOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const app = await createAdapterApp(config, args);
if (!app.isBuilt()) {
if (config.onBuilt) {
@@ -120,9 +102,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args,
opts?: RuntimeOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const app = await createAdapterApp(config, args);
if (!app.isBuilt()) {
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 type { NextApiRequest } from "next";
@@ -10,9 +10,8 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<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"]) {
@@ -41,10 +40,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (req: Request) => {
const app = await getApp(config, args, opts);
const app = await getApp(config, args);
const request = getCleanRequest(req, cleanRequest);
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 { omitKeys } from "bknd/utils";
export type NodeSqliteConnectionConfig = {
database: DatabaseSync;
};
export type NodeSqliteConnection = GenericSqliteConnection<DatabaseSync>;
export type NodeSqliteConnectionConfig = Omit<
GenericSqliteConnectionConfig<DatabaseSync>,
"name" | "supports"
> &
({ database?: DatabaseSync; url?: never } | { url?: string; database?: never });
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
let db: DatabaseSync;
export function nodeSqlite(config?: NodeSqliteConnectionConfig) {
let db: DatabaseSync | undefined;
if (config) {
if ("database" in config) {
if ("database" in config && config.database) {
db = config.database;
} else {
} else if (config.url) {
db = new DatabaseSync(config.url);
}
} else {
}
if (!db) {
db = new DatabaseSync(":memory:");
}
@@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
"node-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
const getStmt = (sql: string) => db.prepare(sql);
return {
db,
@@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
};
},
{
...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
supports: {
batching: false,
},
@@ -1,8 +1,13 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
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 { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("NodeSqliteConnection", () => {
connectionTestSuite(viTestRunner, {
@@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => {
}),
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 { serveStatic } from "@hono/node-server/serve-static";
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 { $console } from "bknd/utils";
@@ -18,7 +18,6 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.relative(
process.cwd(),
@@ -36,19 +35,17 @@ export async function createApp<Env = NodeEnv>(
},
// @ts-ignore
args ?? { env: process.env },
opts,
);
}
export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args ?? (process.env as Env));
}
return app.fetch(req);
};
@@ -57,13 +54,12 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
honoServe(
{
port,
hostname,
fetch: createHandler(config, args, opts),
fetch: createHandler(config, args),
},
(connInfo) => {
$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 { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("node adapter", () => {
adapterTestSuite(viTestRunner, {
@@ -1,9 +1,13 @@
import { describe } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs";
import path from "node:path";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("StorageLocalAdapter (node)", async () => {
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
+3 -2
View File
@@ -1,5 +1,5 @@
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";
// Track mock function calls
@@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = {
}),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: () => {},
afterAll: after,
beforeAll: before,
};
+2 -1
View File
@@ -1,5 +1,5 @@
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) {
return test(label, fn as any);
@@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = {
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: afterAll,
beforeAll: beforeAll,
};
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("react-router adapter", () => {
adapterTestSuite(bunTestRunner, {
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 { FrameworkOptions } from "adapter";
type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = {
@@ -10,17 +9,15 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<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>(
config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
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 { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config";
import type { MiddlewareHandler } from "hono";
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
async function createApp<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> {
registerLocalMediaAdapter();
return await createRuntimeApp(
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
],
},
env,
opts,
);
}
export function serve<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
return {
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);
},
};
+6 -3
View File
@@ -278,7 +278,9 @@ export class Authenticator<
}
return payload as any;
} catch (e) {}
} catch (e) {
$console.debug("Authenticator jwt verify error", String(e));
}
return;
}
@@ -396,8 +398,9 @@ export class Authenticator<
if (headers.has("Authorization")) {
const bearerHeader = String(headers.get("Authorization"));
token = bearerHeader.replace("Bearer ", "");
} else if (is_context) {
token = await this.getAuthCookie(c as Context);
} else {
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
token = await this.getAuthCookie(context);
}
if (token) {
+11 -1
View File
@@ -4,6 +4,7 @@ import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions } from "cli/utils/options";
import { $console } from "bknd/utils";
export const config: CliCommand = (program) => {
withConfigOptions(program.command("config"))
@@ -19,7 +20,14 @@ export const config: CliCommand = (program) => {
config = getDefaultConfig();
} else {
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);
@@ -31,5 +39,7 @@ export const config: CliCommand = (program) => {
} else {
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:
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) {
if (subject in subjects) {
await subjects[subject]();
process.exit(0);
} else {
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 { mcp } from "./mcp/mcp";
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 async function serveStatic(server: Platform): Promise<MiddlewareHandler> {
const onNotFound = (path: string) => {
$console.debug("Couldn't resolve static file at", path);
};
switch (server) {
case "node": {
const m = await import("@hono/node-server/serve-static");
const root = getRelativeDistPath() + "/static";
$console.log("Serving static files from", root);
return m.serveStatic({
// somehow different for node
root: getRelativeDistPath() + "/static",
root,
onNotFound,
});
}
case "bun": {
const m = await import("hono/bun");
const root = path.resolve(getRelativeDistPath(), "static");
$console.log("Serving static files from", root);
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 type { CliBkndConfig, CliCommand } from "cli/types";
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 { registries } from "modules/registries";
import c from "picocolors";
@@ -60,7 +60,7 @@ type MakeAppConfig = {
connection?: CreateAppConfig["connection"];
server?: { platform?: Platform };
setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>;
onBuilt?: (app: App) => MaybePromise<void>;
};
async function makeApp(config: MakeAppConfig) {
+2 -2
View File
@@ -8,7 +8,7 @@ export const schema: CliCommand = (program) => {
.option("--pretty", "pretty print")
.action((options) => {
const schema = getDefaultSchema();
// biome-ignore lint/suspicious/noConsoleLog:
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
console.info(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) => {
withConfigOptions(program.command("sync"))
.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("--out <file>", "output file")
.option("--sql", "use sql output")
.action(async (options) => {
const app = await makeAppFromEnv(options);
const schema = app.em.schema();
console.info(c.dim("Checking database state..."));
const stmts = await schema.sync({ drop: options.drop });
console.info("");
@@ -24,22 +25,27 @@ export const sync: CliCommand = (program) => {
// @todo: currently assuming parameters aren't used
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) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`);
} 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);
}
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")}`);
process.exit(0);
});
};
+2
View File
@@ -35,4 +35,6 @@ async function action({
await writeFile(outfile, et.toString());
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),
});
$log.success(`Created user: ${c.cyan(created.email)}`);
process.exit(0);
} catch (e) {
$log.error("Error creating user");
$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)) {
$log.success(`Updated user: ${c.cyan(user.email)}`);
process.exit(0);
} else {
$log.error("Error updating user");
process.exit(1);
}
}
@@ -158,4 +162,5 @@ async function token(app: App, options: any) {
console.log(
`\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 {
// make sure to make unknown as "any"
[key: string]: {
/* [key: string]: {
id: PrimaryFieldType;
[key: string]: any;
};
}; */
// @todo: that's not good, but required for admin options
[key: string]: any;
}
export const config = {
+1
View File
@@ -31,6 +31,7 @@ export type TestRunner = {
beforeEach: (fn: () => MaybePromise<void>) => void;
afterEach: (fn: () => MaybePromise<void>) => void;
afterAll: (fn: () => MaybePromise<void>) => void;
beforeAll: (fn: () => MaybePromise<void>) => void;
};
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 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 {
const nl = indent ? "\n" : "";
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
+2 -1
View File
@@ -38,7 +38,8 @@ export class InvalidSchemaError extends Error {
) {
super(
`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,
error: console.error,
};
let _oldStderr: any;
let _oldStdout: any;
export async function withDisabledConsole<R>(
fn: () => Promise<R>,
@@ -36,10 +38,17 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
severities.forEach((severity) => {
console[severity] = () => null;
});
// Disable stderr
_oldStderr = process.stderr.write;
_oldStdout = process.stdout.write;
process.stderr.write = () => true;
process.stdout.write = () => true;
$console?.setLevel("critical");
}
export function enableConsoleLog() {
process.stderr.write = _oldStderr;
process.stdout.write = _oldStdout;
Object.entries(_oldConsoles).forEach(([severity, 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 T = RepositoryResultJSON<Data>;
// @todo: if none found, still returns meta...
return this.readMany(entity, {
...query,
limit: 1,
+1
View File
@@ -60,6 +60,7 @@ export class DataController extends Controller {
"/sync",
permission(DataPermissions.databaseSync),
mcpTool("data_sync", {
// @todo: should be removed if readonly
annotations: {
destructiveHint: true,
},
@@ -4,6 +4,7 @@ import { getPath } from "bknd/utils";
import * as proto from "data/prototype";
import { createApp } from "App";
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 toDriver/fromDriver tests on all types and fields
@@ -21,7 +22,9 @@ export function connectionTestSuite(
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", () => {
let ctx: Awaited<ReturnType<typeof makeConnection>>;
@@ -247,7 +250,7 @@ export function connectionTestSuite(
const app = createApp({
connection: ctx.connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});
@@ -333,7 +336,7 @@ export function connectionTestSuite(
const app = createApp({
connection: ctx.connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});
@@ -1,7 +1,6 @@
import type { KyselyPlugin, QueryResult } from "kysely";
import {
type IGenericSqlite,
type OnCreateConnection,
type Promisable,
parseBigInt,
buildQueryFn,
@@ -9,6 +8,7 @@ import {
} from "kysely-generic-sqlite";
import { SqliteConnection } from "./SqliteConnection";
import type { ConnQuery, ConnQueryResults, Features } from "../Connection";
import type { MaybePromise } from "bknd";
export type { IGenericSqlite };
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>[]>;
}
export type GenericSqliteConnectionConfig = {
export type GenericSqliteConnectionConfig<Database = unknown> = {
name?: string;
additionalPlugins?: KyselyPlugin[];
excludeTables?: string[];
onCreateConnection?: OnCreateConnection;
onCreateConnection?: (db: Database) => MaybePromise<void>;
supports?: Partial<Features>;
};
@@ -35,7 +35,12 @@ export class GenericSqliteConnection<DB = unknown> extends SqliteConnection<DB>
) {
super({
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,
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>> {
const executor = await this.getExecutor();
if (!executor.batch) {
//$console.debug("Batching is not supported by this database");
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_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
primary_format: s.string({ enum: primaryFieldTypes }),
fields_order: s.array(s.string()).optional(),
},
{ default: {} },
)
@@ -137,7 +138,9 @@ export class Entity<
}
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[] {
@@ -189,9 +192,24 @@ export class Entity<
return this.fields.findIndex((field) => field.name === name) !== -1;
}
getFields(include_virtual: boolean = false): Field[] {
if (include_virtual) return this.fields;
return this.fields.filter((f) => !f.isVirtual());
getFields(opts?: { virtual?: boolean; sorted?: boolean }): Field[] {
const fields = [...this.fields];
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) {
@@ -275,7 +293,7 @@ export class Entity<
fields = this.getFillableFields(options.context);
break;
default:
fields = this.getFields(true);
fields = this.getFields({ virtual: true });
}
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
@@ -309,7 +327,12 @@ export class Entity<
toJSON() {
return {
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,
};
}
+2 -2
View File
@@ -50,7 +50,7 @@ export class EntityTypescript {
indentWidth: 2,
indentChar: " ",
entityCommentMultiline: true,
fieldCommentMultiline: false,
fieldCommentMultiline: true,
};
}
@@ -82,7 +82,7 @@ export class EntityTypescript {
}
typeName(name: string) {
return autoFormatString(name);
return autoFormatString(name).replace(/ /g, "");
}
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;
}
get data() {
get data(): T {
if (this.options.single) {
return this.first().data?.[0];
}
return this.first().data ?? [];
return this.first().data ?? ([] as T);
}
async execute(qb: Compilable | Compilable[]) {
+1
View File
@@ -4,5 +4,6 @@ export * from "./mutation/Mutator";
export * from "./query/Repository";
export * from "./query/WhereBuilder";
export * from "./query/WithBuilder";
export * from "./Result";
export * from "./query/RepositoryResult";
export * from "./mutation/MutatorResult";
@@ -15,17 +15,6 @@ export class EntityIndex {
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) {
this.name = [
unique ? "idx_unique" : "idx",
+9 -2
View File
@@ -180,8 +180,15 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
override getName(): string {
return [
super.getName(),
[this.connectionEntity.name, this.connectionTableMappedName].filter(Boolean),
...Array.from(
new Set(
[
this.type().replace(":", ""),
this.connectionEntity.name,
this.connectionTableMappedName,
].filter(Boolean),
),
),
].join("_");
}
}
@@ -6,6 +6,7 @@ import { EntityRelation, type KyselyJsonFrom, type KyselyQueryBuilder } from "./
import { EntityRelationAnchor } from "./EntityRelationAnchor";
import { type RelationType, RelationTypes } from "./relation-types";
import { s } from "bknd/utils";
import type { PrimaryFieldType } from "bknd";
export type PolymorphicRelationConfig = s.Static<typeof PolymorphicRelation.schema>;
@@ -70,7 +71,7 @@ export class PolymorphicRelation extends EntityRelation<typeof PolymorphicRelati
.groupBy(groupBy);
}
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
override getReferenceQuery(entity: Entity, id: PrimaryFieldType): Partial<RepoQuery> {
const info = this.queryInfo(entity);
return {
+1 -1
View File
@@ -76,7 +76,7 @@ export class SchemaManager {
}
getIntrospectionFromEntity(entity: Entity): IntrospectedTable {
const fields = entity.getFields(false);
const fields = entity.getFields({ virtual: false, sorted: true });
const indices = this.em.getIndicesOf(entity);
// this is intentionally setting values to defaults, like "nullable" and "default"
+3
View File
@@ -29,6 +29,7 @@ export {
type InitialModuleConfigs,
ModuleManagerEvents,
} from "./modules/ModuleManager";
export type * from "modules/ModuleApi";
export type { ServerEnv } from "modules/Controller";
export type { BkndConfig } from "bknd/adapter";
@@ -115,6 +116,7 @@ export {
genericSqlite,
genericSqliteUtils,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "data/connection/sqlite/GenericSqliteConnection";
export {
EntityTypescript,
@@ -128,6 +130,7 @@ export type { EntityRelation } from "data/relations";
export type * from "data/entities/Entity";
export type { EntityManager } from "data/entities/EntityManager";
export type { SchemaManager } from "data/schema/SchemaManager";
export type * from "data/entities";
export {
BaseIntrospector,
Connection,
+2 -4
View File
@@ -181,16 +181,14 @@ export class MediaController extends Controller {
"param",
s.object({
entity: entitiesEnum,
id: s.number(),
id: s.anyOf([s.number(), s.string()]),
field: s.string(),
}),
),
jsc("query", s.object({ overwrite: s.boolean().optional() })),
permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]),
async (c) => {
const entity_name = c.req.param("entity");
const field_name = c.req.param("field");
const entity_id = Number.parseInt(c.req.param("id"));
const { entity: entity_name, id: entity_id, field: field_name } = c.req.valid("param");
// check if entity exists
const entity = this.media.em.entity(entity_name);
+1 -1
View File
@@ -9,6 +9,6 @@ export const mediaFields = {
etag: text(),
modified_at: datetime(),
reference: text(),
entity_id: number(),
entity_id: text(),
metadata: json(),
};
+1 -1
View File
@@ -103,7 +103,7 @@ export class Storage implements EmitsEvents {
...dim,
};
} 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 { StorageAdapter } from "../../StorageAdapter";
export const cloudinaryAdapterConfig = s.object(
{
cloud_name: s.string(),
api_key: s.string(),
api_secret: s.string(),
api_key: secret(),
api_secret: secret(),
upload_preset: s.string().optional(),
},
{ title: "Cloudinary", description: "Cloudinary media storage" },
@@ -8,15 +8,15 @@ import type {
} from "@aws-sdk/client-s3";
import { AwsClient } from "core/clients/aws/AwsClient";
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 type { FileBody, FileListObject } from "../../Storage";
import { StorageAdapter } from "../../StorageAdapter";
export const s3AdapterConfig = s.object(
{
access_key: s.string(),
secret_access_key: s.string(),
access_key: secret(),
secret_access_key: secret(),
url: s.string({
pattern: "^https?://(?:.*)?[^/.]+$",
description: "URL to S3 compatible endpoint without trailing slash",
+75 -421
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 { Guard } from "auth/authorize/Guard";
import { env } from "core/env";
import { BkndError } from "core/errors";
import { EventManager, Event } from "core/events";
import * as $diff from "core/object/diff";
import type { Connection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager";
import * as proto from "data/prototype";
import { TransformPersistFailedException } from "data/errors";
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 { Module, type ModuleBuildContext } from "./Module";
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 };
@@ -47,13 +42,8 @@ export type ModuleSchemas = {
export type ModuleConfigs = {
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
};
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type InitialModuleConfigs =
| ({
version: number;
} & ModuleConfigs)
| PartialRec<ModuleConfigs>;
export type InitialModuleConfigs = { version?: number } & PartialRec<ModuleConfigs>;
enum Verbosity {
silent = 0,
@@ -80,42 +70,14 @@ export type ModuleManagerOptions = {
seed?: (ctx: ModuleBuildContext) => Promise<void>;
// called right after modules are built, before finish
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
// whether to store secrets in the database
storeSecrets?: boolean;
// provided secrets
secrets?: Record<string, any>;
/** @deprecated */
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");
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
@@ -127,8 +89,14 @@ export class ModuleManagerConfigUpdateEvent<
}> {
static override slug = "mm-config-update";
}
export class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{
secrets: Record<string, any>;
}> {
static override slug = "mm-secrets-extracted";
}
export const ModuleManagerEvents = {
ModuleManagerConfigUpdateEvent,
ModuleManagerSecretsExtractedEvent,
};
// @todo: cleanup old diffs on upgrade
@@ -137,8 +105,6 @@ export class ModuleManager {
static Events = ModuleManagerEvents;
protected modules: Modules;
// internal em for __bknd config table
__em!: EntityManager<T_INTERNAL_EM>;
// ctx for modules
em!: EntityManager;
server!: Hono<ServerEnv>;
@@ -146,42 +112,24 @@ export class ModuleManager {
guard!: Guard;
mcp!: ModuleBuildContext["mcp"];
private _version: number = 0;
private _built = false;
private readonly _booted_with?: "provided" | "partial";
private _stable_configs: ModuleConfigs | undefined;
protected _built = false;
private logger: DebugLogger;
protected logger: DebugLogger;
constructor(
private readonly connection: Connection,
private options?: Partial<ModuleManagerOptions>,
protected readonly connection: Connection,
protected options?: Partial<ModuleManagerOptions>,
) {
this.__em = new EntityManager([__bknd], this.connection);
this.modules = {} as Modules;
this.emgr = new EventManager({ ...ModuleManagerEvents });
this.logger = new DebugLogger(debug_modules);
let initial = {} as Partial<ModuleConfigs>;
if (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);
this.createModules(initial);
this.createModules(options?.initial ?? {});
}
private createModules(initial: Partial<ModuleConfigs>) {
protected onModuleConfigUpdated(key: string, config: any) {}
private createModules(initial: PartialRec<ModuleConfigs>) {
this.logger.context("createModules").log("creating modules");
try {
const context = this.ctx(true);
@@ -211,46 +159,7 @@ export class ModuleManager {
return this._built;
}
/**
* 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() {
protected rebuildServer() {
this.server = new Hono<ServerEnv>();
if (this.options?.basePath) {
this.server = this.server.basePath(this.options.basePath);
@@ -299,252 +208,81 @@ export class ModuleManager {
};
}
private async fetch(): Promise<ConfigTable | undefined> {
this.logger.context("fetch").log("fetching");
const startTime = performance.now();
extractSecrets() {
const moduleConfigs = structuredClone(this.configs());
const secrets = { ...this.options?.secrets };
const extractedKeys: string[] = [];
// disabling console log, because the table might not exist yet
const { data: result } = await this.repo().findOne(
{ type: "config" },
{
sort: { by: "version", dir: "desc" },
},
);
for (const [key, module] of Object.entries(this.modules)) {
const config = moduleConfigs[key];
const schema = module.getSchema();
if (!result) {
this.logger.log("error fetching").clear();
return undefined;
}
const extracted = [...schema.walk({ data: config })].filter(
(n) => n.schema instanceof SecretSchema,
);
this.logger
.log("took", performance.now() - startTime, "ms", {
version: result.version,
id: result.id,
})
.clear();
for (const n of extracted) {
const path = [key, ...n.instancePath].join(".");
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");
if (typeof n.data === "string") {
extractedKeys.push(path);
secrets[path] = n.data;
setPath(moduleConfigs, path, "");
}
}
} 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;
return {
configs: moduleConfigs,
secrets: pick(secrets, extractedKeys),
extractedKeys,
};
}
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 {
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
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 {
// 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) {
console.error(e);
throw new Error(
`Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`,
);
}
});
}
}
async build(opts?: { fetch?: boolean }) {
this.logger.context("build").log("version", this.version());
await this.ctx().connection.init();
async build(opts?: any) {
this.createModules(this.options?.initial ?? {});
await this.buildModules();
// if no config provided, try fetch from db
if (this.version() === 0 || opts?.fetch === true) {
if (opts?.fetch) {
this.logger.log("force fetch");
}
// 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();
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();
for (const key of extractedKeys) {
if (key in provided_secrets) {
setPath(configs, key, provided_secrets[key]);
}
}
} else {
if (this.version() !== CURRENT_VERSION) {
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.setConfigs(configs);
await this.buildModules();
}
this.logger.log("done");
this.logger.clear();
return this;
}
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
protected async buildModules(options?: {
graceful?: boolean;
ignoreFlags?: boolean;
drop?: boolean;
}) {
const state = {
built: false,
modules: [] as ModuleKey[],
@@ -580,12 +318,8 @@ export class ModuleManager {
this.logger.log("db sync requested");
// sync db
await ctx.em.schema().sync({ force: true });
await ctx.em.schema().sync({ force: true, drop: options?.drop });
state.synced = true;
// save
await this.save();
state.saved = true;
}
if (ctx.flags.ctx_reload_required) {
@@ -601,92 +335,12 @@ export class ModuleManager {
ctx.flags = Module.ctx_flags;
// storing last stable config version
this._stable_configs = $diff.clone(this.configs());
//this._stable_configs = $diff.clone(this.configs());
this.logger.clear();
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] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);
@@ -695,7 +349,7 @@ export class ModuleManager {
}
version() {
return this._version;
return 0;
}
built() {

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