From e6ff5c3f0bf1d3eb4d9517f00d6ce6b969693a97 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 11 Oct 2025 20:37:14 +0200 Subject: [PATCH 1/6] fix pagination if endpoint's total is not available when using a connection that has softscans disabled (e.g. D1) pagination failed. Fixing it by overfetching and slicing --- app/src/ui/client/api/use-api.ts | 6 +- app/src/ui/components/table/DataTable.tsx | 87 ++++++++++++------- .../ui/elements/media/DropzoneContainer.tsx | 69 +++++++++------ app/src/ui/hooks/use-search.ts | 5 +- .../ui/modules/data/components/EntityForm.tsx | 14 +-- .../fields/EntityRelationalFormField.tsx | 2 +- app/src/ui/routes/data/data.$entity.index.tsx | 2 +- 7 files changed, 110 insertions(+), 75 deletions(-) diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 72feeeb..4571bd9 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -35,7 +35,7 @@ export const useApiInfiniteQuery = < RefineFn extends (data: ResponseObject) => unknown = (data: ResponseObject) => Data, >( fn: (api: Api, page: number) => FetchPromise, - options?: SWRConfiguration & { refine?: RefineFn }, + options?: SWRConfiguration & { refine?: RefineFn; pageSize?: number }, ) => { const [endReached, setEndReached] = useState(false); const api = useApi(); @@ -47,14 +47,14 @@ export const useApiInfiniteQuery = < // @ts-ignore const swr = useSWRInfinite( (index, previousPageData: any) => { - if (previousPageData && !previousPageData.length) { + if (index > 0 && previousPageData && previousPageData.length < (options?.pageSize ?? 0)) { setEndReached(true); return null; // reached the end } return promise(index).request.url; }, (url: string) => { - return new FetchPromise(new Request(url), { fetcher: api.fetcher }, refine).execute(); + return new FetchPromise(new Request(url), { fetcher: api.fetcher }).execute(); }, { revalidateFirstPage: false, diff --git a/app/src/ui/components/table/DataTable.tsx b/app/src/ui/components/table/DataTable.tsx index 2ce2a42..ce691f9 100644 --- a/app/src/ui/components/table/DataTable.tsx +++ b/app/src/ui/components/table/DataTable.tsx @@ -53,7 +53,7 @@ export type DataTableProps = { }; export function DataTable = Record>({ - data = [], + data: _data = [], columns, checkable, onClickRow, @@ -71,11 +71,14 @@ export function DataTable = Record renderValue, onClickNew, }: DataTableProps) { + const hasTotal = !!total; + const data = Array.isArray(_data) ? _data.slice(0, perPage) : _data; total = total || data?.length || 0; page = page || 1; const select = columns && columns.length > 0 ? columns : Object.keys(data?.[0] || {}); const pages = Math.max(Math.ceil(total / perPage), 1); + const hasNext = hasTotal ? pages > page : (_data?.length || 0) > perPage; const CellRender = renderValue || CellValue; return ( @@ -202,7 +205,7 @@ export function DataTable = Record perPage={perPage} page={page} items={data?.length || 0} - total={total} + total={hasTotal ? total : undefined} />
@@ -222,11 +225,17 @@ export function DataTable = Record
)}
- Page {page} of {pages} + Page {page} + {hasTotal ? <> of {pages} : ""}
{onClickPage && (
- +
)} @@ -268,17 +277,23 @@ const SortIndicator = ({ }; const TableDisplay = ({ perPage, page, items, total }) => { - if (total === 0) { + if (items === 0 && page === 1) { return <>No rows to show; } - if (total === 1) { - return <>Showing 1 row; + const start = Math.max(perPage * (page - 1), 1); + + if (!total) { + return ( + <> + Showing {start}-{perPage * (page - 1) + items} + + ); } return ( <> - Showing {perPage * (page - 1) + 1}-{perPage * (page - 1) + items} of {total} rows + Showing {start}-{perPage * (page - 1) + items} of {total} rows ); }; @@ -287,30 +302,44 @@ type TableNavProps = { current: number; total: number; onClick?: (page: number) => void; + hasLast?: boolean; }; -const TableNav: React.FC = ({ current, total, onClick }: TableNavProps) => { +const TableNav: React.FC = ({ + current, + total, + onClick, + hasLast = true, +}: TableNavProps) => { const navMap = [ - { value: 1, Icon: TbChevronsLeft, disabled: current === 1 }, - { value: current - 1, Icon: TbChevronLeft, disabled: current === 1 }, - { value: current + 1, Icon: TbChevronRight, disabled: current === total }, - { value: total, Icon: TbChevronsRight, disabled: current === total }, + { enabled: true, value: 1, Icon: TbChevronsLeft, disabled: current === 1 }, + { enabled: true, value: current - 1, Icon: TbChevronLeft, disabled: current === 1 }, + { + enabled: true, + value: current + 1, + Icon: TbChevronRight, + disabled: current === total, + }, + { enabled: hasLast, value: total, Icon: TbChevronsRight, disabled: current === total }, ] as const; - return navMap.map((nav, key) => ( - - )); + return navMap.map( + (nav, key) => + nav.enabled && ( + + ), + ); }; diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 46cef55..2a99a5f 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -77,7 +77,9 @@ export function DropzoneContainer({ }); const $q = infinite - ? useApiInfiniteQuery(selectApi, {}) + ? useApiInfiniteQuery(selectApi, { + pageSize, + }) : useApiQuery(selectApi, { enabled: initialItems !== false && !initialItems, revalidateOnFocus: false, @@ -108,31 +110,48 @@ export function DropzoneContainer({ []) as MediaFieldSchema[]; const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl }); - const key = id + JSON.stringify(_initialItems); + const key = id + JSON.stringify(initialItems); + + // check if endpoint reeturns a total, then reaching end is easy + const total = "_data" in $q ? $q._data?.[0]?.body.meta.count : undefined; + let placeholderLength = 0; + if (infinite && "setSize" in $q) { + placeholderLength = + typeof total === "number" + ? total + : $q.endReached + ? _initialItems.length + : _initialItems.length + pageSize; + + // in case there is no total, we overfetch but SWR don't reflect an empty result + // therefore we check if it stopped loading, but has a bigger page size than the total. + // if that's the case, we assume we reached the end. + if (!total && !$q.isValidating && pageSize * $q.size >= placeholderLength) { + placeholderLength = _initialItems.length; + } + } + return ( - $q.setSize($q.size + 1)} - /> - ) - } - {...props} - /> + <> + $q.setSize($q.size + 1)} + /> + ) + } + {...props} + /> + ); } diff --git a/app/src/ui/hooks/use-search.ts b/app/src/ui/hooks/use-search.ts index 40967c1..be0cad0 100644 --- a/app/src/ui/hooks/use-search.ts +++ b/app/src/ui/hooks/use-search.ts @@ -14,10 +14,6 @@ export function useSearch( ) { const searchString = useWouterSearch(); const [location, navigate] = useLocation(); - const [value, setValue] = useState>( - options?.defaultValue ?? ({} as any), - ); - const defaults = useMemo(() => { return mergeObject( // @ts-ignore @@ -25,6 +21,7 @@ export function useSearch( options?.defaultValue ?? {}, ); }, [JSON.stringify({ schema, dflt: options?.defaultValue })]); + const [value, setValue] = useState>(defaults); useEffect(() => { const initial = diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 63e608e..ff778a1 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -301,19 +301,9 @@ function EntityJsonFormField({ onChange={handleUpdate} onBlur={fieldApi.handleBlur} minHeight="100" - /*required={field.isRequired()}*/ {...props} /> - {/**/} ); } @@ -340,8 +330,8 @@ function EntityEnumFormField({ {...props} > {!field.isRequired() && } - {field.getOptions().map((option) => ( - ))} diff --git a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx index 6b4ee77..aa331bd 100644 --- a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx @@ -44,7 +44,7 @@ export function EntityRelationalFormField({ const ref = useRef(null); const $q = useEntityQuery(field.target(), undefined, { select: query.select, - limit: query.limit, + limit: query.limit + 1 /* overfetch for softscan=false */, offset: (query.page - 1) * query.limit, }); const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>(); diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 10b74fa..5945f37 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -61,7 +61,7 @@ function DataEntityListImpl({ params }) { (api) => api.data.readMany(entity?.name as any, { select: search.value.select, - limit: search.value.perPage, + limit: search.value.perPage + 1 /* overfetch for softscan=false */, offset: (search.value.page - 1) * search.value.perPage, sort: `${search.value.sort.dir === "asc" ? "" : "-"}${search.value.sort.by}`, }), From fd3dd310a5dbf091b812ed0312bee162749d7d45 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 13 Oct 2025 10:41:15 +0200 Subject: [PATCH 2/6] refactor: enhance MediaApi typing and improve vite example config handling for d1 Updated `MediaApi` to include improved generic typing for upload methods, ensuring type safety and consistency. Refactored example configuration logic in development environment setup for better modularity and maintainability. --- app/src/media/api/MediaApi.ts | 19 ++++++----- app/src/ui/client/api/use-entity.ts | 18 ++++++---- app/vite.dev.ts | 53 ++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 22 deletions(-) diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index db94b03..d925d4d 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -1,11 +1,14 @@ import type { FileListObject } from "media/storage/Storage"; import { type BaseModuleApiOptions, + type FetchPromise, + type ResponseObject, ModuleApi, type PrimaryFieldType, type TInput, } from "modules/ModuleApi"; import type { ApiFetcher } from "Api"; +import type { DB, FileUploadedEventData } from "bknd"; export type MediaApiOptions = BaseModuleApiOptions & { upload_fetcher: ApiFetcher; @@ -67,14 +70,14 @@ export class MediaApi extends ModuleApi { return new Headers(); } - protected uploadFile( + protected uploadFile( body: File | Blob | ReadableStream | Buffer, opts?: { filename?: string; path?: TInput; _init?: Omit; }, - ) { + ): FetchPromise> { const headers = { "Content-Type": "application/octet-stream", ...(opts?._init?.headers || {}), @@ -106,10 +109,10 @@ export class MediaApi extends ModuleApi { throw new Error("Invalid filename"); } - return this.post(opts?.path ?? ["upload", name], body, init); + return this.post(opts?.path ?? ["upload", name], body, init); } - async upload( + async upload( item: Request | Response | string | File | Blob | ReadableStream | Buffer, opts: { filename?: string; @@ -124,12 +127,12 @@ export class MediaApi extends ModuleApi { if (!res.ok || !res.body) { throw new Error("Failed to fetch file"); } - return this.uploadFile(res.body, opts); + return this.uploadFile(res.body, opts); } else if (item instanceof Response) { if (!item.body) { throw new Error("Invalid response"); } - return this.uploadFile(item.body, { + return this.uploadFile(item.body, { ...(opts ?? {}), _init: { ...(opts._init ?? {}), @@ -141,7 +144,7 @@ export class MediaApi extends ModuleApi { }); } - return this.uploadFile(item, opts); + return this.uploadFile(item, opts); } async uploadToEntity( @@ -153,7 +156,7 @@ export class MediaApi extends ModuleApi { _init?: Omit; fetcher?: typeof fetch; }, - ) { + ): Promise> { return this.upload(item, { ...opts, path: ["entity", entity, id, field], diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index b77b57d..f53798c 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -1,8 +1,14 @@ -import type { DB, PrimaryFieldType, EntityData, RepoQueryIn } from "bknd"; +import type { + DB, + PrimaryFieldType, + EntityData, + RepoQueryIn, + RepositoryResult, + ResponseObject, + ModuleApi, +} from "bknd"; import { objectTransform, encodeSearch } from "bknd/utils"; -import type { RepositoryResult } from "data/entities"; import type { Insertable, Selectable, Updateable } from "kysely"; -import type { FetchPromise, ModuleApi, ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import { type Api, useApi } from "ui/client"; @@ -108,7 +114,7 @@ export function makeKey( ); } -interface UseEntityQueryReturn< +export interface UseEntityQueryReturn< Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined = undefined, Data = Entity extends keyof DB ? Selectable : EntityData, @@ -136,11 +142,11 @@ export const useEntityQuery = < const fetcher = () => read(query ?? {}); type T = Awaited>; - const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, { + const swr = useSWR(options?.enabled === false ? null : key, fetcher as any, { revalidateOnFocus: false, keepPreviousData: true, ...options, - }); + }) as ReturnType>; const mutateFn = async (id?: PrimaryFieldType) => { const entityKey = makeKey(api, entity as string, id); diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 6f74ff1..255fe26 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -1,4 +1,4 @@ -import { readFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { serveStatic } from "@hono/node-server/serve-static"; import { showRoutes } from "hono/dev"; import { App, registries, type CreateAppConfig } from "./src"; @@ -9,6 +9,7 @@ import { $console } from "core/utils/console"; import { createClient } from "@libsql/client"; import util from "node:util"; import { d1Sqlite } from "adapter/cloudflare/connection/D1Connection"; +import { slugify } from "./src/core/utils/strings"; util.inspect.defaultOptions.depth = 5; registries.media.register("local", StorageLocalAdapter); @@ -21,16 +22,19 @@ $console.debug("Using db type", dbType); let dbUrl = import.meta.env.VITE_DB_URL ?? ":memory:"; const example = import.meta.env.VITE_EXAMPLE; -if (example) { - const configPath = `.configs/${example}.json`; - $console.debug("Loading config from", configPath); - const exampleConfig = JSON.parse(await readFile(configPath, "utf-8")); - config.config = exampleConfig; - dbUrl = `file:.configs/${example}.db`; +async function loadExampleConfig() { + if (example) { + const configPath = `.configs/${example}.json`; + $console.debug("Loading config from", configPath); + const exampleConfig = JSON.parse(await readFile(configPath, "utf-8")); + config.config = exampleConfig; + dbUrl = `file:.configs/${example}.db`; + } } switch (dbType) { case "libsql": { + await loadExampleConfig(); $console.debug("Using libsql connection", dbUrl); const authToken = import.meta.env.VITE_DB_LIBSQL_TOKEN; config.connection = libsql( @@ -43,15 +47,48 @@ switch (dbType) { } case "d1": { $console.debug("Using d1 connection"); + const wranglerConfig = { + name: "vite-dev", + main: "src/index.ts", + compatibility_date: "2025-08-03", + compatibility_flags: ["nodejs_compat"], + d1_databases: [ + { + binding: "DB", + database_name: "vite-dev", + database_id: "00000000-0000-0000-0000-000000000000", + }, + ], + r2_buckets: [ + { + binding: "BUCKET", + bucket_name: "vite-dev", + }, + ], + }; + let configPath = ".configs/vite.wrangler.json"; + if (example) { + const name = slugify(example); + configPath = `.configs/${slugify(example)}.wrangler.json`; + const exists = await readFile(configPath, "utf-8"); + if (!exists) { + wranglerConfig.name = name; + wranglerConfig.d1_databases[0]!.database_name = name; + wranglerConfig.d1_databases[0]!.database_id = crypto.randomUUID(); + wranglerConfig.r2_buckets[0]!.bucket_name = name; + await writeFile(configPath, JSON.stringify(wranglerConfig, null, 2)); + } + } const { getPlatformProxy } = await import("wrangler"); const platformProxy = await getPlatformProxy({ - configPath: "./vite.wrangler.json", + configPath, }); config.connection = d1Sqlite({ binding: platformProxy.env.DB as any }); break; } default: { + await loadExampleConfig(); $console.debug("Using node-sqlite connection", dbUrl); config.connection = nodeSqlite({ url: dbUrl }); break; From 3f9be3a4182ff772b833a4f7d65f8fa845b11420 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 13 Oct 2025 10:46:04 +0200 Subject: [PATCH 3/6] fix: refine FetchPromise execution in useApiInfiniteQuery Updated the FetchPromise execution in the useApiInfiniteQuery function to include a refine parameter, enhancing the request handling process. --- app/src/ui/client/api/use-api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 4571bd9..6b6d546 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -54,7 +54,7 @@ export const useApiInfiniteQuery = < return promise(index).request.url; }, (url: string) => { - return new FetchPromise(new Request(url), { fetcher: api.fetcher }).execute(); + return new FetchPromise(new Request(url), { fetcher: api.fetcher }, refine).execute(); }, { revalidateFirstPage: false, From 0352c72fb6e0256dc6afde83aeba0a9a7752dd30 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 13 Oct 2025 10:51:47 +0200 Subject: [PATCH 4/6] docs: add note about Cloudflare Image Optimization plugin requirement Included a callout in the documentation for the Cloudflare Image Optimization plugin, clarifying that it does not function on the development server or with `workers.dev` subdomains, and requires enabling Cloudflare Image transformations. --- docs/content/docs/(documentation)/extending/plugins.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx index 9d6ff59..1ab0fa1 100644 --- a/docs/content/docs/(documentation)/extending/plugins.mdx +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -167,6 +167,10 @@ export default { ### `cloudflareImageOptimization` + + This plugin doesn't work on the development server, or on workers deployed with a `workers.dev` subdomain. It requires [Cloudflare Image transformations to be enabled](https://developers.cloudflare.com/images/get-started/#enable-transformations-on-your-zone) on your zone. + + A plugin that add Cloudflare Image Optimization to your app's media storage. ```typescript title="bknd.config.ts" From f4a7cde4873d87b331499aaabdff5e7c1200fcd4 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 13 Oct 2025 10:58:33 +0200 Subject: [PATCH 5/6] chore: bump version to 0.18.1 and update jsonv-ts dependency to 0.8.5 --- app/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/package.json b/app/package.json index 9486c50..f932580 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.18.0", + "version": "0.18.1", "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": { @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.8.4", + "jsonv-ts": "0.8.5", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", From 22e43c2523142baa778708c837fda2fb9cfcb4bb Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 18 Oct 2025 16:58:54 +0200 Subject: [PATCH 6/6] feat: introduce new modes helpers --- app/src/App.ts | 1 + app/src/adapter/astro/astro.adapter.ts | 9 +- app/src/adapter/bun/bun.adapter.ts | 12 +- app/src/adapter/bun/index.ts | 8 + app/src/adapter/index.ts | 39 ++-- app/src/adapter/nextjs/nextjs.adapter.ts | 6 +- app/src/adapter/node/index.ts | 10 + app/src/adapter/node/node.adapter.ts | 11 +- .../react-router/react-router.adapter.ts | 6 +- app/src/core/types.ts | 4 + app/src/index.ts | 2 +- app/src/modes/code.ts | 49 +++++ app/src/modes/hybrid.ts | 88 +++++++++ app/src/modes/index.ts | 3 + app/src/modes/shared.ts | 183 ++++++++++++++++++ app/src/modules/db/DbModuleManager.ts | 7 +- app/tsconfig.json | 4 +- 17 files changed, 402 insertions(+), 40 deletions(-) create mode 100644 app/src/modes/code.ts create mode 100644 app/src/modes/hybrid.ts create mode 100644 app/src/modes/index.ts create mode 100644 app/src/modes/shared.ts diff --git a/app/src/App.ts b/app/src/App.ts index 0f535f8..b8cbde7 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -385,6 +385,7 @@ export class App< } } } + await this.options?.manager?.onModulesBuilt?.(ctx); } } diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 7f24923..92a8604 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -8,12 +8,15 @@ export type AstroBkndConfig = FrameworkBkndConfig; export async function getApp( config: AstroBkndConfig = {}, - args: Env = {} as Env, + args: Env = import.meta.env as Env, ) { - return await createFrameworkApp(config, args ?? import.meta.env); + return await createFrameworkApp(config, args); } -export function serve(config: AstroBkndConfig = {}, args: Env = {} as Env) { +export function serve( + config: AstroBkndConfig = {}, + args: Env = import.meta.env as Env, +) { return async (fnArgs: TAstro) => { return (await getApp(config, args)).fetch(fnArgs.request); }; diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 00b61b5..44e7ccf 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -12,7 +12,7 @@ export type BunBkndConfig = RuntimeBkndConfig & Omit( { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); registerLocalMediaAdapter(); @@ -26,18 +26,18 @@ export async function createApp( }), ...config, }, - args ?? (process.env as Env), + args, ); } export function createHandler( config: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { let app: App | undefined; return async (req: Request) => { if (!app) { - app = await createApp(config, args ?? (process.env as Env)); + app = await createApp(config, args); } return app.fetch(req); }; @@ -54,9 +54,10 @@ export function serve( buildConfig, adminOptions, serveStatic, + beforeBuild, ...serveOptions }: BunBkndConfig = {}, - args: Env = {} as Env, + args: Env = Bun.env as Env, ) { Bun.serve({ ...serveOptions, @@ -71,6 +72,7 @@ export function serve( adminOptions, distPath, serveStatic, + beforeBuild, }, args, ), diff --git a/app/src/adapter/bun/index.ts b/app/src/adapter/bun/index.ts index 5f85135..a0ca1ed 100644 --- a/app/src/adapter/bun/index.ts +++ b/app/src/adapter/bun/index.ts @@ -1,3 +1,11 @@ export * from "./bun.adapter"; export * from "../node/storage"; export * from "./connection/BunSqliteConnection"; + +export async function writer(path: string, content: string) { + await Bun.write(path, content); +} + +export async function reader(path: string) { + return await Bun.file(path).text(); +} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 2548efa..949aaba 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -6,18 +6,23 @@ import { guessMimeType, type MaybePromise, registries as $registries, + type Merge, } from "bknd"; import { $console } from "bknd/utils"; import type { Context, MiddlewareHandler, Next } from "hono"; import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; -export type BkndConfig = CreateAppConfig & { - app?: Omit | ((args: Args) => MaybePromise, "app">>); - onBuilt?: (app: App) => MaybePromise; - beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; - buildConfig?: Parameters[0]; -}; +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 +>; export type FrameworkBkndConfig = BkndConfig; @@ -51,11 +56,10 @@ export async function makeConfig( return { ...rest, ...additionalConfig }; } -// a map that contains all apps by id export async function createAdapterApp( config: Config = {} as Config, args?: Args, -): Promise { +): Promise<{ app: App; config: BkndConfig }> { await config.beforeBuild?.(undefined, $registries); const appConfig = await makeConfig(config, args); @@ -65,34 +69,37 @@ export async function createAdapterApp( config: FrameworkBkndConfig = {}, args?: Args, ): Promise { - const app = await createAdapterApp(config, args); + const { app, config: appConfig } = await createAdapterApp(config, args); if (!app.isBuilt()) { if (config.onBuilt) { app.emgr.onEvent( App.Events.AppBuiltEvent, async () => { - await config.onBuilt?.(app); + await appConfig.onBuilt?.(app); }, "sync", ); } - await config.beforeBuild?.(app, $registries); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } @@ -103,7 +110,7 @@ export async function createRuntimeApp( { serveStatic, adminOptions, ...config }: RuntimeBkndConfig = {}, args?: Args, ): Promise { - const app = await createAdapterApp(config, args); + const { app, config: appConfig } = await createAdapterApp(config, args); if (!app.isBuilt()) { app.emgr.onEvent( @@ -116,7 +123,7 @@ export async function createRuntimeApp( app.modules.server.get(path, handler); } - await config.onBuilt?.(app); + await appConfig.onBuilt?.(app); if (adminOptions !== false) { app.registerAdminController(adminOptions); } @@ -124,7 +131,7 @@ export async function createRuntimeApp( "sync", ); - await config.beforeBuild?.(app, $registries); + await appConfig.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index ba0953b..eed1c35 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -9,9 +9,9 @@ export type NextjsBkndConfig = FrameworkBkndConfig & { export async function getApp( config: NextjsBkndConfig, - args: Env = {} as Env, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? (process.env as Env)); + return await createFrameworkApp(config, args); } function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { @@ -39,7 +39,7 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ export function serve( { cleanRequest, ...config }: NextjsBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { return async (req: Request) => { const app = await getApp(config, args); diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index b430450..befd771 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -1,3 +1,13 @@ +import { readFile, writeFile } from "node:fs/promises"; + export * from "./node.adapter"; export * from "./storage"; export * from "./connection/NodeSqliteConnection"; + +export async function writer(path: string, content: string) { + await writeFile(path, content); +} + +export async function reader(path: string) { + return await readFile(path, "utf-8"); +} diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index fd96086..83feba8 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -17,7 +17,7 @@ export type NodeBkndConfig = RuntimeBkndConfig & { export async function createApp( { distPath, relativeDistPath, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { const root = path.relative( process.cwd(), @@ -33,19 +33,18 @@ export async function createApp( serveStatic: serveStatic({ root }), ...config, }, - // @ts-ignore - args ?? { env: process.env }, + args, ); } export function createHandler( config: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { let app: App | undefined; return async (req: Request) => { if (!app) { - app = await createApp(config, args ?? (process.env as Env)); + app = await createApp(config, args); } return app.fetch(req); }; @@ -53,7 +52,7 @@ export function createHandler( export function serve( { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { honoServe( { diff --git a/app/src/adapter/react-router/react-router.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts index f37260d..f624bde 100644 --- a/app/src/adapter/react-router/react-router.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -8,14 +8,14 @@ export type ReactRouterBkndConfig = FrameworkBkndConfig( config: ReactRouterBkndConfig, - args: Env = {} as Env, + args: Env = process.env as Env, ) { - return await createFrameworkApp(config, args ?? process.env); + return await createFrameworkApp(config, args); } export function serve( config: ReactRouterBkndConfig = {}, - args: Env = {} as Env, + args: Env = process.env as Env, ) { return async (fnArgs: ReactRouterFunctionArgs) => { return (await getApp(config, args)).fetch(fnArgs.request); diff --git a/app/src/core/types.ts b/app/src/core/types.ts index 03beae5..c0550db 100644 --- a/app/src/core/types.ts +++ b/app/src/core/types.ts @@ -6,3 +6,7 @@ export interface Serializable { export type MaybePromise = T | Promise; export type PartialRec = { [P in keyof T]?: PartialRec }; + +export type Merge = { + [K in keyof T]: T[K]; +}; diff --git a/app/src/index.ts b/app/src/index.ts index ae01151..4f48439 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -41,7 +41,7 @@ export { getSystemMcp } from "modules/mcp/system-mcp"; /** * Core */ -export type { MaybePromise } from "core/types"; +export type { MaybePromise, Merge } from "core/types"; export { Exception, BkndError } from "core/errors"; export { isDebug, env } from "core/env"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts new file mode 100644 index 0000000..30e4dc3 --- /dev/null +++ b/app/src/modes/code.ts @@ -0,0 +1,49 @@ +import type { BkndConfig } from "bknd/adapter"; +import { makeModeConfig, type BkndModeConfig } from "./shared"; +import { $console } from "bknd/utils"; + +export type BkndCodeModeConfig = BkndModeConfig; + +export type CodeMode = AdapterConfig extends BkndConfig< + infer Args +> + ? BkndModeConfig + : never; + +export function code(config: BkndCodeModeConfig): BkndConfig { + return { + ...config, + app: async (args) => { + const { + config: appConfig, + plugins, + isProd, + syncSchemaOptions, + } = await makeModeConfig(config, args); + + if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") { + $console.warn("You should not set a different mode than `db` when using code mode"); + } + + return { + ...appConfig, + options: { + ...appConfig?.options, + mode: "code", + plugins, + manager: { + // skip validation in prod for a speed boost + skipValidation: isProd, + onModulesBuilt: async (ctx) => { + if (!isProd && syncSchemaOptions.force) { + $console.log("[code] syncing schema"); + await ctx.em.schema().sync(syncSchemaOptions); + } + }, + ...appConfig?.options?.manager, + }, + }, + }; + }, + }; +} diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts new file mode 100644 index 0000000..b545270 --- /dev/null +++ b/app/src/modes/hybrid.ts @@ -0,0 +1,88 @@ +import type { BkndConfig } from "bknd/adapter"; +import { makeModeConfig, type BkndModeConfig } from "./shared"; +import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd"; +import type { DbModuleManager } from "modules/db/DbModuleManager"; +import { invariant, $console } from "bknd/utils"; + +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; + /** + * Provided secrets to be merged into the configuration + */ + secrets?: Record; +}; + +export type HybridBkndConfig = BkndModeConfig; +export type HybridMode = AdapterConfig extends BkndConfig< + infer Args +> + ? BkndModeConfig> + : never; + +export function hybrid({ + configFilePath = "bknd-config.json", + ...rest +}: HybridBkndConfig): BkndConfig { + return { + ...rest, + config: undefined, + app: async (args) => { + const { + config: appConfig, + isProd, + plugins, + syncSchemaOptions, + } = await makeModeConfig( + { + ...rest, + configFilePath, + }, + args, + ); + + if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") { + $console.warn("You should not set a different mode than `db` when using hybrid mode"); + } + invariant( + typeof appConfig.reader === "function", + "You must set the `reader` option when using hybrid mode", + ); + + let fileConfig: ModuleConfigs; + try { + fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs; + } catch (e) { + const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs; + await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2)); + fileConfig = defaultConfig; + } + + return { + ...(appConfig as any), + beforeBuild: async (app) => { + if (app && !isProd) { + const mm = app.modules as DbModuleManager; + mm.buildSyncConfig = syncSchemaOptions; + } + }, + config: fileConfig, + options: { + ...appConfig?.options, + mode: isProd ? "code" : "db", + plugins, + manager: { + // skip validation in prod for a speed boost + skipValidation: isProd, + // secrets are required for hybrid mode + secrets: appConfig.secrets, + ...appConfig?.options?.manager, + }, + }, + }; + }, + }; +} diff --git a/app/src/modes/index.ts b/app/src/modes/index.ts new file mode 100644 index 0000000..b053671 --- /dev/null +++ b/app/src/modes/index.ts @@ -0,0 +1,3 @@ +export * from "./code"; +export * from "./hybrid"; +export * from "./shared"; diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts new file mode 100644 index 0000000..afc2c6a --- /dev/null +++ b/app/src/modes/shared.ts @@ -0,0 +1,183 @@ +import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd"; +import { syncTypes, syncConfig } from "bknd/plugins"; +import { syncSecrets } from "plugins/dev/sync-secrets.plugin"; +import { invariant, $console } from "bknd/utils"; + +export type BkndModeOptions = { + /** + * Whether the application is running in production. + */ + isProduction?: boolean; + /** + * Writer function to write the configuration to the file system + */ + writer?: (path: string, content: string) => MaybePromise; + /** + * Configuration file path + */ + configFilePath?: string; + /** + * Types file path + * @default "bknd-types.d.ts" + */ + typesFilePath?: string; + /** + * Syncing secrets options + */ + syncSecrets?: { + /** + * Whether to enable syncing secrets + */ + enabled?: boolean; + /** + * Output file path + */ + outFile?: string; + /** + * Format of the output file + * @default "env" + */ + format?: "json" | "env"; + /** + * Whether to include secrets in the output file + * @default false + */ + includeSecrets?: boolean; + }; + /** + * Determines whether to automatically sync the schema if not in production. + * @default true + */ + syncSchema?: boolean | { force?: boolean; drop?: boolean }; +}; + +export type BkndModeConfig = BkndConfig< + Args, + Merge +>; + +export async function makeModeConfig< + Args = any, + Config extends BkndModeConfig = BkndModeConfig, +>(_config: Config, args: Args) { + const appConfig = typeof _config.app === "function" ? await _config.app(args) : _config.app; + + const config = { + ..._config, + ...appConfig, + } as Omit; + + if (typeof config.isProduction !== "boolean") { + $console.warn( + "You should set `isProduction` option when using managed modes to prevent accidental issues", + ); + } + + invariant( + typeof config.writer === "function", + "You must set the `writer` option when using managed modes", + ); + + const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; + + const isProd = config.isProduction; + const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const syncSchemaOptions = + typeof config.syncSchema === "object" + ? config.syncSchema + : { + force: config.syncSchema !== false, + drop: true, + }; + + if (!isProd) { + if (typesFilePath) { + if (plugins.some((p) => p.name === "bknd-sync-types")) { + throw new Error("You have to unregister the `syncTypes` plugin"); + } + plugins.push( + syncTypes({ + enabled: true, + includeFirstBoot: true, + write: async (et) => { + try { + await config.writer?.(typesFilePath, et.toString()); + } catch (e) { + console.error(`Error writing types to"${typesFilePath}"`, e); + } + }, + }) as any, + ); + } + + if (configFilePath) { + if (plugins.some((p) => p.name === "bknd-sync-config")) { + throw new Error("You have to unregister the `syncConfig` plugin"); + } + plugins.push( + syncConfig({ + enabled: true, + includeFirstBoot: true, + write: async (config) => { + try { + await writer?.(configFilePath, JSON.stringify(config, null, 2)); + } catch (e) { + console.error(`Error writing config to "${configFilePath}"`, e); + } + }, + }) as any, + ); + } + + if (syncSecretsOptions?.enabled) { + if (plugins.some((p) => p.name === "bknd-sync-secrets")) { + throw new Error("You have to unregister the `syncSecrets` plugin"); + } + + let outFile = syncSecretsOptions.outFile; + const format = syncSecretsOptions.format ?? "env"; + if (!outFile) { + outFile = ["env", !syncSecretsOptions.includeSecrets && "example", format] + .filter(Boolean) + .join("."); + } + + plugins.push( + syncSecrets({ + enabled: true, + includeFirstBoot: true, + write: async (secrets) => { + const values = Object.fromEntries( + Object.entries(secrets).map(([key, value]) => [ + key, + syncSecretsOptions.includeSecrets ? value : "", + ]), + ); + + try { + if (format === "env") { + await writer?.( + outFile, + Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join("\n"), + ); + } else { + await writer?.(outFile, JSON.stringify(values, null, 2)); + } + } catch (e) { + console.error(`Error writing secrets to "${outFile}"`, e); + } + }, + }) as any, + ); + } + } + + return { + config, + isProd, + plugins, + syncSchemaOptions, + }; +} diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts index a7bc903..8af95e8 100644 --- a/app/src/modules/db/DbModuleManager.ts +++ b/app/src/modules/db/DbModuleManager.ts @@ -70,6 +70,9 @@ export class DbModuleManager extends ModuleManager { private readonly _booted_with?: "provided" | "partial"; private _stable_configs: ModuleConfigs | undefined; + // config used when syncing database + public buildSyncConfig: { force?: boolean; drop?: boolean } = { force: true }; + constructor(connection: Connection, options?: Partial) { let initial = {} as InitialModuleConfigs; let booted_with = "partial" as any; @@ -393,7 +396,7 @@ export class DbModuleManager extends ModuleManager { const version_before = this.version(); const [_version, _configs] = await migrate(version_before, result.configs.json, { - db: this.db + db: this.db, }); this._version = _version; @@ -463,7 +466,7 @@ export class DbModuleManager extends ModuleManager { this.logger.log("db sync requested"); // sync db - await ctx.em.schema().sync({ force: true }); + await ctx.em.schema().sync(this.buildSyncConfig); state.synced = true; // save diff --git a/app/tsconfig.json b/app/tsconfig.json index 55264d4..10260b4 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -33,7 +33,9 @@ "bknd": ["./src/index.ts"], "bknd/utils": ["./src/core/utils/index.ts"], "bknd/adapter": ["./src/adapter/index.ts"], - "bknd/client": ["./src/ui/client/index.ts"] + "bknd/adapter/*": ["./src/adapter/*/index.ts"], + "bknd/client": ["./src/ui/client/index.ts"], + "bknd/modes": ["./src/modes/index.ts"] } }, "include": [