From a2fa11ccd0a3050c449b91c0bd2c3ad06583311e Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 20 Nov 2025 21:08:16 +0100 Subject: [PATCH 1/3] refactor modes implementation and improve validation handling refactor `code` and `hybrid` modes for better type safety and configuration flexibility. add `_isProd` helper to standardize environment checks and improve plugin syncing warnings. adjust validation logic for clean JSON schema handling and enhance test coverage for modes. --- .vscode/settings.json | 2 +- app/__test__/app/modes.test.ts | 42 +++++++++++++++++ app/src/adapter/bun/bun.adapter.ts | 2 + app/src/adapter/cloudflare/proxy.ts | 46 ++++++++----------- app/src/adapter/node/node.adapter.ts | 2 +- app/src/data/fields/JsonSchemaField.ts | 4 +- app/src/modes/code.ts | 9 ++-- app/src/modes/hybrid.ts | 25 +++++----- app/src/modes/shared.ts | 32 +++++++++---- app/src/modules/ModuleManager.ts | 2 +- .../(documentation)/usage/introduction.mdx | 16 +++---- 11 files changed, 119 insertions(+), 63 deletions(-) create mode 100644 app/__test__/app/modes.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 7787afb..5c3e1c6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "biome.enabled": true, "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { - "source.organizeImports.biome": "explicit", + //"source.organizeImports.biome": "explicit", "source.fixAll.biome": "explicit" }, "typescript.preferences.importModuleSpecifier": "non-relative", diff --git a/app/__test__/app/modes.test.ts b/app/__test__/app/modes.test.ts new file mode 100644 index 0000000..034cf92 --- /dev/null +++ b/app/__test__/app/modes.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { code, hybrid } from "modes"; + +describe("modes", () => { + describe("code", () => { + test("verify base configuration", async () => { + const c = code({}) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["options"]); + expect(config.options.mode).toEqual("code"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + + test("keeps overrides", async () => { + const c = code({ + connection: { + url: ":memory:", + }, + }) as any; + const config = await c.app?.({} as any); + expect(config.connection.url).toEqual(":memory:"); + }); + }); + + describe("hybrid", () => { + test("fails if no reader is provided", () => { + // @ts-ignore + expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/); + }); + test("verify base configuration", async () => { + const c = hybrid({ reader: async () => ({}) }) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]); + expect(config.options.mode).toEqual("db"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 51c9be3..c5d640d 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -43,6 +43,7 @@ export function createHandler( export function serve( { + app, distPath, connection, config: _config, @@ -62,6 +63,7 @@ export function serve( port, fetch: createHandler( { + app, connection, config: _config, options, diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts index 9efd5c4..3476315 100644 --- a/app/src/adapter/cloudflare/proxy.ts +++ b/app/src/adapter/cloudflare/proxy.ts @@ -65,37 +65,31 @@ export function withPlatformProxy( } return { - ...config, - beforeBuild: async (app, registries) => { - if (!use_proxy) return; - const env = await getEnv(); - registerMedia(env, registries as any); - await config?.beforeBuild?.(app, registries); - }, - bindings: async (env) => { - return (await config?.bindings?.(await getEnv(env))) || {}; - }, // @ts-ignore app: async (_env) => { const env = await getEnv(_env); const binding = use_proxy ? getBinding(env, "D1Database") : undefined; + const appConfig = typeof config.app === "function" ? await config.app(env) : config; + const connection = + use_proxy && binding + ? d1Sqlite({ + binding: binding.value as any, + }) + : appConfig.connection; - if (config?.app === undefined && use_proxy && binding) { - return { - connection: d1Sqlite({ - binding: binding.value, - }), - }; - } else if (typeof config?.app === "function") { - const appConfig = await config?.app(env); - if (binding) { - appConfig.connection = d1Sqlite({ - binding: binding.value, - }) as any; - } - return appConfig; - } - return config?.app || {}; + return { + ...appConfig, + beforeBuild: async (app, registries) => { + if (!use_proxy) return; + const env = await getEnv(); + registerMedia(env, registries as any); + await config?.beforeBuild?.(app, registries); + }, + bindings: async (env) => { + return (await config?.bindings?.(await getEnv(env))) || {}; + }, + connection, + }; }, } satisfies CloudflareBkndConfig; } diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 83feba8..d85d197 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -24,7 +24,7 @@ export async function createApp( path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), ); if (relativeDistPath) { - console.warn("relativeDistPath is deprecated, please use distPath instead"); + $console.warn("relativeDistPath is deprecated, please use distPath instead"); } registerLocalMediaAdapter(); diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index fed47bf..1bc2bbc 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -26,7 +26,9 @@ export class JsonSchemaField< constructor(name: string, config: Partial) { super(name, config); - this.validator = new Validator({ ...this.getJsonSchema() }); + + // make sure to hand over clean json + this.validator = new Validator(JSON.parse(JSON.stringify(this.getJsonSchema()))); } protected getSchema() { diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts index 30e4dc3..6c147a3 100644 --- a/app/src/modes/code.ts +++ b/app/src/modes/code.ts @@ -10,16 +10,19 @@ export type CodeMode = AdapterConfig extends B ? BkndModeConfig : never; -export function code(config: BkndCodeModeConfig): BkndConfig { +export function code< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(codeConfig: CodeMode): BkndConfig { return { - ...config, + ...codeConfig, app: async (args) => { const { config: appConfig, plugins, isProd, syncSchemaOptions, - } = await makeModeConfig(config, args); + } = await makeModeConfig(codeConfig, args); if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") { $console.warn("You should not set a different mode than `db` when using code mode"); diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts index c7c1c37..ce72630 100644 --- a/app/src/modes/hybrid.ts +++ b/app/src/modes/hybrid.ts @@ -1,6 +1,6 @@ import type { BkndConfig } from "bknd/adapter"; import { makeModeConfig, type BkndModeConfig } from "./shared"; -import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd"; +import { getDefaultConfig, type MaybePromise, type Merge } from "bknd"; import type { DbModuleManager } from "modules/db/DbModuleManager"; import { invariant, $console } from "bknd/utils"; @@ -9,7 +9,7 @@ export type BkndHybridModeOptions = { * Reader function to read the configuration from the file system. * This is required for hybrid mode to work. */ - reader?: (path: string) => MaybePromise; + reader: (path: string) => MaybePromise; /** * Provided secrets to be merged into the configuration */ @@ -23,8 +23,12 @@ export type HybridMode = AdapterConfig extends ? BkndModeConfig> : never; -export function hybrid(hybridConfig: HybridBkndConfig): BkndConfig { +export function hybrid< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(hybridConfig: HybridMode): BkndConfig { return { + ...hybridConfig, app: async (args) => { const { config: appConfig, @@ -40,16 +44,15 @@ export function hybrid(hybridConfig: HybridBkndConfig): BkndConfig = BkndConfig< Merge >; +function _isProd() { + try { + return process.env.NODE_ENV === "production"; + } catch (_e) { + return false; + } +} + export async function makeModeConfig< Args = any, Config extends BkndModeConfig = BkndModeConfig, @@ -69,25 +77,24 @@ export async function makeModeConfig< if (typeof config.isProduction !== "boolean") { $console.warn( - "You should set `isProduction` option when using managed modes to prevent accidental issues", + "You should set `isProduction` option when using managed modes to prevent accidental issues with writing plugins and syncing schema. As fallback, it is set to", + _isProd(), ); } - invariant( - typeof config.writer === "function", - "You must set the `writer` option when using managed modes", - ); + let needsWriter = false; const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; - const isProd = config.isProduction; + const isProd = config.isProduction ?? _isProd(); const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd; const syncSchemaOptions = typeof config.syncSchema === "object" ? config.syncSchema : { - force: config.syncSchema !== false, - drop: true, + force: syncFallback, + drop: syncFallback, }; if (!isProd) { @@ -95,6 +102,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-types")) { throw new Error("You have to unregister the `syncTypes` plugin"); } + needsWriter = true; plugins.push( syncTypes({ enabled: true, @@ -114,6 +122,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-config")) { throw new Error("You have to unregister the `syncConfig` plugin"); } + needsWriter = true; plugins.push( syncConfig({ enabled: true, @@ -142,6 +151,7 @@ export async function makeModeConfig< .join("."); } + needsWriter = true; plugins.push( syncSecrets({ enabled: true, @@ -174,6 +184,10 @@ export async function makeModeConfig< } } + if (needsWriter && typeof config.writer !== "function") { + $console.warn("You must set a `writer` function, attempts to write will fail"); + } + return { config, isProd, diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 8406eaa..706a8fd 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -223,7 +223,7 @@ export class ModuleManager { } extractSecrets() { - const moduleConfigs = structuredClone(this.configs()); + const moduleConfigs = JSON.parse(JSON.stringify(this.configs())); const secrets = { ...this.options?.secrets }; const extractedKeys: string[] = []; diff --git a/docs/content/docs/(documentation)/usage/introduction.mdx b/docs/content/docs/(documentation)/usage/introduction.mdx index adf6810..c290a12 100644 --- a/docs/content/docs/(documentation)/usage/introduction.mdx +++ b/docs/content/docs/(documentation)/usage/introduction.mdx @@ -213,9 +213,9 @@ To use it, you have to wrap your configuration in a mode helper, e.g. for `code` import { code, type CodeMode } from "bknd/modes"; import { type BunBkndConfig, writer } from "bknd/adapter/bun"; -const config = { +export default code({ // some normal bun bknd config - connection: { url: "file:test.db" }, + connection: { url: "file:data.db" }, // ... // a writer is required, to sync the types writer, @@ -227,9 +227,7 @@ const config = { force: true, drop: true, } -} satisfies CodeMode; - -export default code(config); +}); ``` Similarily, for `hybrid` mode: @@ -238,9 +236,9 @@ Similarily, for `hybrid` mode: import { hybrid, type HybridMode } from "bknd/modes"; import { type BunBkndConfig, writer, reader } from "bknd/adapter/bun"; -const config = { +export default hybrid({ // some normal bun bknd config - connection: { url: "file:test.db" }, + connection: { url: "file:data.db" }, // ... // reader/writer are required, to sync the types and config writer, @@ -262,7 +260,5 @@ const config = { force: true, drop: true, }, -} satisfies HybridMode; - -export default hybrid(config); +}); ``` \ No newline at end of file From 5c3eeb7642645e708d8e0995bb9f0826eec4a9da Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 20 Nov 2025 21:11:28 +0100 Subject: [PATCH 2/3] fix json schema validation initialization ensure `getJsonSchema` handles both object and non-object outputs to prevent errors during validation initialization. this improves robustness when handling edge cases in schema configurations. --- app/src/data/fields/JsonSchemaField.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index 1bc2bbc..b182ac1 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -28,7 +28,10 @@ export class JsonSchemaField< super(name, config); // make sure to hand over clean json - this.validator = new Validator(JSON.parse(JSON.stringify(this.getJsonSchema()))); + const schema = this.getJsonSchema(); + this.validator = new Validator( + typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {}, + ); } protected getSchema() { From c3ae4a3999743432cac972199bc91fa1f20be8fa Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 21 Nov 2025 17:35:45 +0100 Subject: [PATCH 3/3] fix cloudflare adapter warm mode and type improvements Adds warm mode support to cloudflare adapter to enable app instance caching. Improves BkndConfig type handling and makes hybrid mode reader optional. Fixes entity hook types to properly handle Generated ID types from kysely. --- .../cloudflare/cloudflare-workers.adapter.ts | 13 ++++++++++--- app/src/adapter/index.ts | 17 +++++++++-------- app/src/modes/hybrid.ts | 4 ++-- app/src/ui/client/api/use-entity.ts | 9 ++++++--- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index e263756..98df2b0 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -3,7 +3,7 @@ import type { RuntimeBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import type { MaybePromise } from "bknd"; +import type { App, MaybePromise } from "bknd"; import { $console } from "bknd/utils"; import { createRuntimeApp } from "bknd/adapter"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; @@ -55,8 +55,12 @@ export async function createApp( // compatiblity export const getFresh = createApp; +let app: App | undefined; export function serve( config: CloudflareBkndConfig = {}, + serveOptions?: (args: Env) => { + warm?: boolean; + }, ) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { @@ -92,8 +96,11 @@ export function serve( } } - const context = { request, env, ctx } as CloudflareContext; - const app = await createApp(config, context); + const { warm } = serveOptions?.(env) ?? {}; + if (!app || warm !== true) { + const context = { request, env, ctx } as CloudflareContext; + app = await createApp(config, context); + } return app.fetch(request, env, ctx); }, diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 79f4c97..6568d29 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -14,14 +14,15 @@ import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; export type BkndConfig = Merge< - CreateAppConfig & { - app?: - | Merge & Additional> - | ((args: Args) => MaybePromise, "app"> & Additional>>); - onBuilt?: (app: App) => MaybePromise; - beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; - buildConfig?: Parameters[0]; - } & Additional + CreateAppConfig & + Omit & { + app?: + | Omit, "app"> + | ((args: Args) => MaybePromise, "app">>); + onBuilt?: (app: App) => MaybePromise; + beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; + buildConfig?: Parameters[0]; + } >; export type FrameworkBkndConfig = BkndConfig; diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts index ce72630..40fca8c 100644 --- a/app/src/modes/hybrid.ts +++ b/app/src/modes/hybrid.ts @@ -9,7 +9,7 @@ export type BkndHybridModeOptions = { * Reader function to read the configuration from the file system. * This is required for hybrid mode to work. */ - reader: (path: string) => MaybePromise; + reader?: (path: string) => MaybePromise; /** * Provided secrets to be merged into the configuration */ @@ -47,7 +47,7 @@ export function hybrid< "You must set a `reader` option when using hybrid mode", ); - const fileContent = await appConfig.reader(configFilePath); + const fileContent = await appConfig.reader?.(configFilePath); let fileConfig = typeof fileContent === "string" ? JSON.parse(fileContent) : fileContent; if (!fileConfig) { $console.warn("No config found, using default config"); diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index f53798c..1042344 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -8,7 +8,7 @@ import type { ModuleApi, } from "bknd"; import { objectTransform, encodeSearch } from "bknd/utils"; -import type { Insertable, Selectable, Updateable } from "kysely"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import { type Api, useApi } from "ui/client"; @@ -33,6 +33,7 @@ interface UseEntityReturn< Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined, Data = Entity extends keyof DB ? DB[Entity] : EntityData, + ActualId = Data extends { id: infer I } ? (I extends Generated ? T : I) : never, Response = ResponseObject>>, > { create: (input: Insertable) => Promise; @@ -42,9 +43,11 @@ interface UseEntityReturn< ResponseObject[] : Selectable>> >; update: Id extends undefined - ? (input: Updateable, id: Id) => Promise + ? (input: Updateable, id: ActualId) => Promise : (input: Updateable) => Promise; - _delete: Id extends undefined ? (id: Id) => Promise : () => Promise; + _delete: Id extends undefined + ? (id: PrimaryFieldType) => Promise + : () => Promise; } export const useEntity = <