diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 064ec5c..8cd1d99 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,10 +1,7 @@ { "mcpServers": { "bknd": { - "url": "http://localhost:3000/mcp", - "headers": { - "API_KEY": "value" - } + "url": "http://localhost:28623/api/system/mcp" } } } diff --git a/README.md b/README.md index ab56a80..8761a0e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ bknd simplifies app development by providing a fully functional backend for data **For documentation and examples, please visit https://docs.bknd.io.** > [!WARNING] -> This project requires Node.js 22 or higher (because of `node:sqlite`). +> This project requires Node.js 22.13 or higher (because of `node:sqlite`). > > Please keep in mind that **bknd** is still under active development > and therefore full backward compatibility is not guaranteed before reaching v1.0.0. diff --git a/app/build.ts b/app/build.ts index 2d0caa9..66db700 100644 --- a/app/build.ts +++ b/app/build.ts @@ -61,8 +61,11 @@ function delayTypes() { watcher_timeout = setTimeout(buildTypes, 1000); } +const dependencies = Object.keys(pkg.dependencies); + // collection of always-external packages const external = [ + ...dependencies, "bun:test", "node:test", "node:assert/strict", @@ -86,10 +89,10 @@ async function buildApi() { outDir: "dist", external: [...external], metafile: true, + target: "esnext", platform: "browser", format: ["esm"], splitting: false, - treeshake: true, loader: { ".svg": "dataurl", }, @@ -245,6 +248,8 @@ async function buildAdapters() { // base adapter handles tsup.build({ ...baseConfig(""), + target: "esnext", + platform: "neutral", entry: ["src/adapter/index.ts"], outDir: "dist/adapter", }), diff --git a/app/e2e/adapters.ts b/app/e2e/adapters.ts index 9ca8154..0dd009b 100644 --- a/app/e2e/adapters.ts +++ b/app/e2e/adapters.ts @@ -17,7 +17,7 @@ async function run( }); // Read from stdout - const reader = proc.stdout.getReader(); + const reader = (proc.stdout as ReadableStream).getReader(); const decoder = new TextDecoder(); // Function to read chunks @@ -30,7 +30,7 @@ async function run( const text = decoder.decode(value); if (!resolveCalled) { - console.log(c.dim(text.replace(/\n$/, ""))); + console.info(c.dim(text.replace(/\n$/, ""))); } onChunk( text, @@ -189,21 +189,21 @@ const adapters = { async function testAdapter(name: keyof typeof adapters) { const config = adapters[name]; - console.log("adapter", c.cyan(name)); + console.info("adapter", c.cyan(name)); await config.clean(); const { proc, data } = await config.start(); - console.log("proc:", proc.pid, "data:", c.cyan(data)); + console.info("proc:", proc.pid, "data:", c.cyan(data)); //proc.kill();process.exit(0); const add_env = "env" in config && config.env ? config.env : ""; await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`; - console.log("DONE!"); + console.info("DONE!"); while (!proc.killed) { proc.kill("SIGINT"); await Bun.sleep(250); - console.log("Waiting for process to exit..."); + console.info("Waiting for process to exit..."); } } diff --git a/app/internal/docs.build-assets.ts b/app/internal/docs.build-assets.ts index 5ad062c..4a4db73 100644 --- a/app/internal/docs.build-assets.ts +++ b/app/internal/docs.build-assets.ts @@ -7,6 +7,7 @@ async function generate() { server: { mcp: { enabled: true, + path: "/mcp", }, }, auth: { diff --git a/app/package.json b/app/package.json index ccbfec8..e623a02 100644 --- a/app/package.json +++ b/app/package.json @@ -15,7 +15,7 @@ }, "packageManager": "bun@1.2.19", "engines": { - "node": ">=22" + "node": ">=22.13" }, "scripts": { "dev": "BKND_CLI_LOG_LEVEL=debug vite", @@ -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 && bun run test:e2e && bun run build:all && cp ../README.md ./", + "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 ./", "postpublish": "rm -f README.md", "test": "ALL_TESTS=1 bun test --bail", "test:all": "bun run test && bun run test:node", @@ -39,8 +39,8 @@ "test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:vitest:coverage": "vitest run --coverage", - "test:e2e": "VITE_DB_URL=:memory: playwright test", - "test:e2e:adapters": "VITE_DB_URL=:memory: bun run e2e/adapters.ts", + "test:e2e": "playwright test", + "test:e2e:adapters": "bun run e2e/adapters.ts", "test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui", "test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug", "test:e2e:report": "VITE_DB_URL=:memory: playwright show-report", @@ -71,6 +71,7 @@ "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "radix-ui": "^1.1.3", + "picocolors": "^1.1.1", "swr": "^2.3.3" }, "devDependencies": { @@ -108,7 +109,6 @@ "libsql-stateless-easy": "^1.8.0", "open": "^10.1.0", "openapi-types": "^12.1.3", - "picocolors": "^1.1.1", "postcss": "^8.5.3", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", diff --git a/app/src/Api.ts b/app/src/Api.ts index d9fb8e4..28d2ef0 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -43,7 +43,7 @@ export type ApiOptions = { } & ( | { token?: string; - user?: TApiUser; + user?: TApiUser | null; } | { request: Request; diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index fbb5e5c..f496253 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -153,7 +153,9 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) { return async (c: Context, next: Next) => { if (!files) { const manifest = - opts?.manifest || ((await import("bknd/dist/manifest.json")).default as Manifest); + opts?.manifest || + ((await import("bknd/dist/manifest.json", { with: { type: "json" } })) + .default as Manifest); files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]); } @@ -161,7 +163,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) { if (files.includes(path)) { try { const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, { - assert: { type: "text" }, + with: { type: "text" }, }).then((m) => m.default); if (content) { diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 33c0df6..ba12d4a 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -221,6 +221,7 @@ export class AuthController extends Controller { return user; }; + const roles = Object.keys(this.auth.config.roles ?? {}); mcp.tool( // @todo: needs permission "auth_user_create", @@ -231,7 +232,7 @@ export class AuthController extends Controller { password: s.string({ minLength: 8 }), role: s .string({ - enum: Object.keys(this.auth.config.roles ?? {}), + enum: roles.length > 0 ? roles : undefined, }) .optional(), }), diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 1c3cd82..41902a9 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -505,3 +505,10 @@ export function deepFreeze(object: T): T { return Object.freeze(object); } + +export function convertNumberedObjectToArray(obj: object): any[] | object { + if (Object.keys(obj).every((key) => Number.isInteger(Number(key)))) { + return Object.values(obj); + } + return obj; +} diff --git a/app/src/core/utils/uuid.ts b/app/src/core/utils/uuid.ts index e3112e2..99511af 100644 --- a/app/src/core/utils/uuid.ts +++ b/app/src/core/utils/uuid.ts @@ -1,10 +1,16 @@ -import { v4, v7 } from "uuid"; +import { v4, v7, validate, version as uuidVersion } from "uuid"; // generates v4 export function uuid(): string { - return v4(); + return v4(); } +// generates v7 export function uuidv7(): string { - return v7(); + return v7(); +} + +// validate uuid +export function uuidValidate(uuid: string, version: 4 | 7): boolean { + return validate(uuid) && uuidVersion(uuid) === version; } diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 18b26c2..163f0af 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,6 +1,15 @@ import type { ModuleBuildContext } from "modules"; import { Controller } from "modules/Controller"; -import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } from "bknd/utils"; +import { + jsc, + s, + describeRoute, + schemaToSpec, + omitKeys, + pickKeys, + mcpTool, + convertNumberedObjectToArray, +} from "bknd/utils"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; import type { EntityManager, EntityData } from "data/entities"; @@ -421,7 +430,13 @@ export class DataController extends Controller { if (!this.entityExists(entity)) { return this.notFound(c); } - const body = (await c.req.json()) as EntityData | EntityData[]; + + const _body = (await c.req.json()) as EntityData | EntityData[]; + // @todo: check on jsonv-ts how to handle this better + // temporary fix for numbered object to array + // this happens when the MCP tool uses the allOf function + // to transform all validation targets into a single object + const body = convertNumberedObjectToArray(_body); if (Array.isArray(body)) { const result = await this.em.mutator(entity).insertMany(body); diff --git a/app/src/data/entities/EntityManager.ts b/app/src/data/entities/EntityManager.ts index 544c8ad..36168f8 100644 --- a/app/src/data/entities/EntityManager.ts +++ b/app/src/data/entities/EntityManager.ts @@ -258,6 +258,9 @@ export class EntityManager { // @todo: centralize and add tests hydrate(entity_name: string, _data: EntityData[]) { + if (!Array.isArray(_data) || _data.length === 0) { + return []; + } const entity = this.entity(entity_name); const data: EntityData[] = []; diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index d15eefe..a68519d 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -11,6 +11,7 @@ import { css, Style } from "hono/css"; import { Controller } from "modules/Controller"; import * as SystemPermissions from "modules/permissions"; import type { TApiUser } from "Api"; +import type { Manifest } from "vite"; const htmlBkndContextReplace = ""; @@ -32,6 +33,7 @@ export type AdminControllerOptions = { debugRerenders?: boolean; theme?: "dark" | "light" | "system"; logoReturnPath?: string; + manifest?: Manifest; }; export class AdminController extends Controller { @@ -194,8 +196,10 @@ export class AdminController extends Controller { }; if (isProd) { - let manifest: any; - if (this.options.assetsPath.startsWith("http")) { + let manifest: Manifest; + if (this.options.manifest) { + manifest = this.options.manifest; + } else if (this.options.assetsPath.startsWith("http")) { manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", { headers: { Accept: "application/json", @@ -204,14 +208,14 @@ export class AdminController extends Controller { } else { // @ts-ignore manifest = await import("bknd/dist/manifest.json", { - assert: { type: "json" }, + with: { type: "json" }, }).then((res) => res.default); } try { // @todo: load all marked as entry (incl. css) - assets.js = manifest["src/ui/main.tsx"].file; - assets.css = manifest["src/ui/main.tsx"].css[0] as any; + assets.js = manifest["src/ui/main.tsx"]?.file!; + assets.css = manifest["src/ui/main.tsx"]?.css?.[0] as any; } catch (e) { $console.warn("Couldn't find assets in manifest", e); } diff --git a/app/vite.dev.ts b/app/vite.dev.ts index f102ee4..f7cc5aa 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -9,6 +9,9 @@ import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection"; import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection"; import { $console } from "core/utils/console"; import { createClient } from "@libsql/client"; +import util from "node:util"; + +util.inspect.defaultOptions.depth = 5; registries.media.register("local", StorageLocalAdapter); diff --git a/bun.lock b/bun.lock index d9765f6..9e26bd2 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.17.0-rc.1", + "version": "0.17.1", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -151,7 +151,6 @@ "bknd": "workspace:*", "kysely-neon": "^1.3.0", "tsup": "^8.4.0", - "typescript": "^5.8.2", }, "optionalDependencies": { "kysely": "^0.27.6", @@ -1232,7 +1231,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], + "@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -3832,10 +3831,6 @@ "@bknd/plasmic/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "@bknd/postgres/@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], - - "@bknd/postgres/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "@bknd/sqlocal/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "@bundled-es-modules/cookie/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], @@ -4078,7 +4073,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], + "@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="], "@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], @@ -4684,8 +4679,6 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], - "@bknd/postgres/@types/bun/bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], - "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], "@cloudflare/vitest-pool-workers/miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index 132289a..c946b6a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app # define bknd version to be used as: # `docker build --build-arg VERSION= -t bknd .` -ARG VERSION=0.13.0 +ARG VERSION=0.17.1 # Install & copy required cli RUN npm install --omit=dev bknd@${VERSION} @@ -16,10 +16,10 @@ FROM node:24-alpine WORKDIR /app -# Install pm2 and libsql +# Install required dependencies RUN npm install -g pm2 RUN echo '{"type":"module"}' > package.json -RUN npm install @libsql/client +RUN npm install jsonv-ts @libsql/client # Create volume and init args VOLUME /data diff --git a/docs/content/docs/(documentation)/extending/config.mdx b/docs/content/docs/(documentation)/extending/config.mdx index f473f5c..8f81579 100644 --- a/docs/content/docs/(documentation)/extending/config.mdx +++ b/docs/content/docs/(documentation)/extending/config.mdx @@ -40,24 +40,49 @@ export type BkndConfig = CreateAppConfig & { onBuilt?: (app: App) => Promise; // passed as the first argument to the `App.build` method buildConfig?: Parameters[0]; - // force the app to be recreated - force?: boolean; - // the id of the app, defaults to `app` - id?: string; }; ``` The supported configuration file extensions are `js`, `ts`, `mjs`, `cjs` and `json`. Throughout the documentation, we'll use `ts` for the file extension. +## Example + +Here is an example of a configuration file that specifies a database connection, registers a plugin, add custom routes using [Hono](https://hono.dev/) and performs a [Kysely](https://kysely.dev/) query. + +```typescript +import type { BkndConfig } from "bknd/adapter"; +import { showRoutes } from "bknd/plugins"; + +export default { + connection: { + url: process.env.DB_URL ?? "file:data.db", + }, + onBuilt: async (app) => { + // `app.server` is a Hono instance + const hono = app.server; + hono.get("/hello", (c) => c.text("Hello World")); + + // for complex queries, you can use Kysely directly + const db = app.connection.kysely; + hono.get("/custom_query", async (c) => { + return c.json(await db.selectFrom("pages").selectAll().execute()); + }); + }, + options: { + plugins: [showRoutes()], + }, +} satisfies BkndConfig; +``` + ### `app` (CreateAppConfig) -The `app` property is a function that returns a `CreateAppConfig` object. It allows to pass in the environment variables to the configuration object. +The `app` property is a function that returns a `CreateAppConfig` object. It allows accessing the adapter specific environment variables. This is especially useful when using the [Cloudflare Workers](/integration/cloudflare) runtime, where the environment variables are only available inside the request handler. ```typescript import type { BkndConfig } from "bknd/adapter"; export default { - app: ({ env }) => ({ + app: (env) => ({ connection: { url: env.DB_URL, }, @@ -104,12 +129,6 @@ export default { }; ``` -### `force` & `id` - -The `force` property is a boolean that forces the app to be recreated. This is mainly useful for serverless environments where the execution environment is re-used, and you may or may not want to recreate the app on every request. - -The `id` property is the reference in a cache map. You may create multiple instances of apps in the same process by using different ids (e.g. multi tenant applications). - ## Framework & Runtime configuration Depending on which framework or runtime you're using to run bknd, the configuration object will extend the `BkndConfig` type with additional properties. diff --git a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx index 6ca9f25..1157e06 100644 --- a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx +++ b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx @@ -52,17 +52,17 @@ export default serve(); // manually specifying a D1 binding: export default serve({ - app: ({ env }) => d1({ binding: env.D1_BINDING }), + app: (env) => d1({ binding: env.D1_BINDING }), }); // or specify binding using `bindings` export default serve({ - bindings: ({ env }) => ({ db: env.D1_BINDING }), + bindings: (env) => ({ db: env.D1_BINDING }), }); // or use LibSQL export default serve({ - app: ({ env }) => ({ url: env.DB_URL }), + app: (env) => ({ url: env.DB_URL }), }); ``` @@ -81,7 +81,7 @@ your browser. Now in order to also server the static admin files, you have to modify the `wrangler.toml` to include the static assets. You can do so by either serving the static using the new [Assets feature](https://developers.cloudflare.com/workers/static-assets/), or the deprecated [Workers Site](https://developers.cloudflare.com/workers/configuration/sites/configuration/). -### Assets +### Assets (recommended) Make sure your assets point to the static assets included in the bknd package: @@ -89,7 +89,7 @@ Make sure your assets point to the static assets included in the bknd package: assets = { directory = "node_modules/bknd/dist/static" } ``` -### Workers Sites +### Workers Sites (legacy) Make sure your site points to the static assets included in the bknd package: @@ -108,6 +108,7 @@ export default serve({ app: () => ({ /* ... */ }), + assets: "kv", // [!code highlight] manifest, // [!code highlight] }); ``` @@ -198,7 +199,7 @@ import { d1 } from "bknd/adapter/cloudflare"; import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; export default withPlatformProxy({ - app: ({ env }) => ({ + app: (env) => ({ connection: d1({ binding: env.DB }), }), }); @@ -216,7 +217,7 @@ Instead, it's recommended to split this configuration into separate files, e.g. import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; export default { - app: ({ env }) => ({ + app: (env) => ({ connection: d1({ binding: env.DB }), }), } satisfies CloudflareBkndConfig; diff --git a/docs/content/docs/(documentation)/usage/cli.mdx b/docs/content/docs/(documentation)/usage/cli.mdx index 2ccd875..ea81bee 100644 --- a/docs/content/docs/(documentation)/usage/cli.mdx +++ b/docs/content/docs/(documentation)/usage/cli.mdx @@ -86,7 +86,7 @@ export default { url: "file:data.db", }, // or use the `app` function which passes the environment variables - app: ({ env }) => ({ + app: (env) => ({ connection: { url: env.DB_URL, }, diff --git a/docs/content/docs/(documentation)/usage/database.mdx b/docs/content/docs/(documentation)/usage/database.mdx index febffdd..4608d62 100644 --- a/docs/content/docs/(documentation)/usage/database.mdx +++ b/docs/content/docs/(documentation)/usage/database.mdx @@ -147,7 +147,7 @@ To manually specify which D1 database to take, you can specify it explicitly: import { serve, d1 } from "bknd/adapter/cloudflare"; export default serve({ - app: ({ env }) => d1({ binding: env.D1_BINDING }), + app: (env) => d1({ binding: env.D1_BINDING }), }); ``` @@ -224,7 +224,7 @@ Example using `@neondatabase/serverless`: import { createCustomPostgresConnection } from "@bknd/postgres"; import { NeonDialect } from "kysely-neon"; -const neon = createCustomPostgresConnection(NeonDialect); +const neon = createCustomPostgresConnection("neon", NeonDialect); serve({ connection: neon({ @@ -247,7 +247,7 @@ const xata = new client({ branch: process.env.XATA_BRANCH, }); -const xataConnection = createCustomPostgresConnection(XataDialect, { +const xataConnection = createCustomPostgresConnection("xata", XataDialect, { supports: { batching: false, }, diff --git a/docs/mcp.json b/docs/mcp.json index df16c4b..7075b1e 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -35,8 +35,7 @@ "minLength": 8 }, "role": { - "type": "string", - "enum": [] + "type": "string" } }, "required": [ @@ -134,10 +133,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -161,10 +167,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -194,10 +207,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -235,10 +255,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -276,10 +303,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" } @@ -297,10 +331,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -334,10 +375,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -410,10 +458,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -462,10 +517,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -494,10 +556,17 @@ ], "properties": { "entity": { - "type": "string", - "enum": [ - "users", - "media" + "anyOf": [ + { + "type": "string", + "enum": [ + "users", + "media" + ] + }, + { + "type": "string" + } ], "$target": "param" }, @@ -3994,6 +4063,10 @@ "enabled": { "type": "boolean", "default": false + }, + "path": { + "type": "string", + "default": "/api/system/mcp" } } } diff --git a/packages/postgres/README.md b/packages/postgres/README.md index b66395b..ed5d1e6 100644 --- a/packages/postgres/README.md +++ b/packages/postgres/README.md @@ -59,7 +59,7 @@ You can create a custom kysely postgres dialect by using the `createCustomPostgr ```ts import { createCustomPostgresConnection } from "@bknd/postgres"; -const connection = createCustomPostgresConnection(MyDialect)({ +const connection = createCustomPostgresConnection("my_postgres_dialect", MyDialect)({ // your custom dialect configuration supports: { batching: true @@ -75,7 +75,7 @@ const connection = createCustomPostgresConnection(MyDialect)({ import { createCustomPostgresConnection } from "@bknd/postgres"; import { NeonDialect } from "kysely-neon"; -const connection = createCustomPostgresConnection(NeonDialect)({ +const connection = createCustomPostgresConnection("neon", NeonDialect)({ connectionString: process.env.NEON, }); ``` @@ -94,7 +94,7 @@ const xata = new client({ branch: process.env.XATA_BRANCH, }); -const connection = createCustomPostgresConnection(XataDialect, { +const connection = createCustomPostgresConnection("xata", XataDialect, { supports: { batching: false, }, diff --git a/packages/postgres/package.json b/packages/postgres/package.json index 6d77c10..036d6a9 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,6 +1,6 @@ { "name": "@bknd/postgres", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", @@ -31,8 +31,7 @@ "@xata.io/kysely": "^0.2.1", "bknd": "workspace:*", "kysely-neon": "^1.3.0", - "tsup": "^8.4.0", - "typescript": "^5.8.2" + "tsup": "^8.4.0" }, "tsup": { "entry": ["src/index.ts"], diff --git a/packages/postgres/src/PgPostgresConnection.ts b/packages/postgres/src/PgPostgresConnection.ts index 85a5c84..c96e693 100644 --- a/packages/postgres/src/PgPostgresConnection.ts +++ b/packages/postgres/src/PgPostgresConnection.ts @@ -1,12 +1,13 @@ import { Kysely, PostgresDialect } from "kysely"; import { PostgresIntrospector } from "./PostgresIntrospector"; import { PostgresConnection, plugins } from "./PostgresConnection"; -import { customIntrospector } from "bknd/data"; +import { customIntrospector } from "bknd"; import $pg from "pg"; export type PgPostgresConnectionConfig = $pg.PoolConfig; export class PgPostgresConnection extends PostgresConnection { + override name = "pg"; private pool: $pg.Pool; constructor(config: PgPostgresConnectionConfig) { diff --git a/packages/postgres/src/PostgresConnection.ts b/packages/postgres/src/PostgresConnection.ts index 9ea1d2c..ff67991 100644 --- a/packages/postgres/src/PostgresConnection.ts +++ b/packages/postgres/src/PostgresConnection.ts @@ -1,4 +1,11 @@ -import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "bknd/data"; +import { + Connection, + type DbFunctions, + type FieldSpec, + type SchemaResponse, + type ConnQuery, + type ConnQueryResults, +} from "bknd"; import { ParseJSONResultsPlugin, type ColumnDataType, @@ -13,12 +20,13 @@ export type QB = SelectQueryBuilder; export const plugins = [new ParseJSONResultsPlugin()]; -export abstract class PostgresConnection extends Connection { +export abstract class PostgresConnection extends Connection { protected override readonly supported = { batching: true, + softscans: true, }; - constructor(kysely: Kysely, fn?: Partial, _plugins?: KyselyPlugin[]) { + constructor(kysely: Kysely, fn?: Partial, _plugins?: KyselyPlugin[]) { super( kysely, fn ?? { @@ -73,13 +81,9 @@ export abstract class PostgresConnection extends Connection { ]; } - protected override async batch( - queries: [...Queries], - ): Promise<{ - [K in keyof Queries]: Awaited>; - }> { + override async executeQueries(...qbs: O): Promise> { return this.kysely.transaction().execute(async (trx) => { - return Promise.all(queries.map((q) => trx.executeQuery(q).then((r) => r.rows))); + return Promise.all(qbs.map((q) => trx.executeQuery(q))); }) as any; } } diff --git a/packages/postgres/src/PostgresIntrospector.ts b/packages/postgres/src/PostgresIntrospector.ts index 82b75ba..4b1c928 100644 --- a/packages/postgres/src/PostgresIntrospector.ts +++ b/packages/postgres/src/PostgresIntrospector.ts @@ -1,5 +1,5 @@ import { type SchemaMetadata, sql } from "kysely"; -import { BaseIntrospector } from "bknd/data"; +import { BaseIntrospector } from "bknd"; type PostgresSchemaSpec = { name: string; diff --git a/packages/postgres/src/PostgresJsConnection.ts b/packages/postgres/src/PostgresJsConnection.ts index 1ab1fe4..deff210 100644 --- a/packages/postgres/src/PostgresJsConnection.ts +++ b/packages/postgres/src/PostgresJsConnection.ts @@ -1,13 +1,15 @@ import { Kysely } from "kysely"; import { PostgresIntrospector } from "./PostgresIntrospector"; import { PostgresConnection, plugins } from "./PostgresConnection"; -import { customIntrospector } from "bknd/data"; +import { customIntrospector } from "bknd"; import { PostgresJSDialect } from "kysely-postgres-js"; import $postgresJs, { type Sql, type Options, type PostgresType } from "postgres"; export type PostgresJsConfig = Options>; export class PostgresJsConnection extends PostgresConnection { + override name = "postgres-js"; + private postgres: Sql; constructor(opts: { postgres: Sql }) { diff --git a/packages/postgres/src/custom.ts b/packages/postgres/src/custom.ts index b7369f1..9d626a0 100644 --- a/packages/postgres/src/custom.ts +++ b/packages/postgres/src/custom.ts @@ -1,9 +1,10 @@ -import type { Constructor } from "bknd/core"; -import { customIntrospector, type DbFunctions } from "bknd/data"; +import { customIntrospector, type DbFunctions } from "bknd"; import { Kysely, type Dialect, type KyselyPlugin } from "kysely"; import { plugins, PostgresConnection } from "./PostgresConnection"; import { PostgresIntrospector } from "./PostgresIntrospector"; +export type Constructor = new (...args: any[]) => T; + export type CustomPostgresConnection = { supports?: PostgresConnection["supported"]; fn?: Partial; @@ -15,17 +16,19 @@ export function createCustomPostgresConnection< T extends Constructor, C extends ConstructorParameters[0], >( + name: string, dialect: Constructor, options?: CustomPostgresConnection, -): (config: C) => PostgresConnection { +): (config: C) => PostgresConnection { const supported = { batching: true, ...((options?.supports ?? {}) as any), }; return (config: C) => - new (class extends PostgresConnection { - protected override readonly supported = supported; + new (class extends PostgresConnection { + override name = name; + override readonly supported = supported; constructor(config: C) { super( diff --git a/packages/postgres/test/suite.ts b/packages/postgres/test/suite.ts index 6e73801..ec72987 100644 --- a/packages/postgres/test/suite.ts +++ b/packages/postgres/test/suite.ts @@ -1,8 +1,11 @@ import { describe, beforeAll, afterAll, expect, it, afterEach } from "bun:test"; import type { PostgresConnection } from "../src"; -import { createApp } from "bknd"; -import * as proto from "bknd/data"; +import { createApp, em, entity, text } from "bknd"; import { disableConsoleLog, enableConsoleLog } from "bknd/utils"; +// @ts-ignore +import { connectionTestSuite } from "$bknd/data/connection/connection-test-suite"; +// @ts-ignore +import { bunTestRunner } from "$bknd/adapter/bun/test"; export type TestSuiteConfig = { createConnection: () => InstanceType; @@ -12,8 +15,9 @@ export type TestSuiteConfig = { export async function defaultCleanDatabase(connection: InstanceType) { const kysely = connection.kysely; - // drop all tables & create new schema + // drop all tables+indexes & create new schema await kysely.schema.dropSchema("public").ifExists().cascade().execute(); + await kysely.schema.dropIndex("public").ifExists().cascade().execute(); await kysely.schema.createSchema("public").execute(); } @@ -32,6 +36,23 @@ export function testSuite(config: TestSuiteConfig) { beforeAll(() => disableConsoleLog(["log", "warn", "error"])); afterAll(() => enableConsoleLog()); + // @todo: postgres seems to add multiple indexes, thus failing the test suite + /* describe("test suite", () => { + connectionTestSuite(bunTestRunner, { + makeConnection: () => { + const connection = config.createConnection(); + return { + connection, + dispose: async () => { + await cleanDatabase(connection, config); + await connection.close(); + }, + }; + }, + rawDialectDetails: [], + }); + }); */ + describe("base", () => { it("should connect to the database", async () => { const connection = config.createConnection(); @@ -73,14 +94,14 @@ export function testSuite(config: TestSuiteConfig) { }); it("should create a basic schema", async () => { - const schema = proto.em( + const schema = em( { - posts: proto.entity("posts", { - title: proto.text().required(), - content: proto.text(), + posts: entity("posts", { + title: text().required(), + content: text(), }), - comments: proto.entity("comments", { - content: proto.text(), + comments: entity("comments", { + content: text(), }), }, (fns, s) => { @@ -153,20 +174,20 @@ export function testSuite(config: TestSuiteConfig) { }); it("should support uuid", async () => { - const schema = proto.em( + const schema = em( { - posts: proto.entity( + posts: entity( "posts", { - title: proto.text().required(), - content: proto.text(), + title: text().required(), + content: text(), }, { primary_format: "uuid", }, ), - comments: proto.entity("comments", { - content: proto.text(), + comments: entity("comments", { + content: text(), }), }, (fns, s) => { @@ -187,8 +208,8 @@ export function testSuite(config: TestSuiteConfig) { // @ts-expect-error expect(config.data.entities?.posts.fields?.id.config?.format).toBe("uuid"); - const em = app.em; - const mutator = em.mutator(em.entity("posts")); + const $em = app.em; + const mutator = $em.mutator($em.entity("posts")); const data = await mutator.insertOne({ title: "Hello", content: "World" }); expect(data.data.id).toBeString(); expect(String(data.data.id).length).toBe(36); diff --git a/packages/postgres/tsconfig.json b/packages/postgres/tsconfig.json index d2359e0..5bb10f6 100644 --- a/packages/postgres/tsconfig.json +++ b/packages/postgres/tsconfig.json @@ -1,29 +1,33 @@ { - "compilerOptions": { - "composite": false, - "module": "ESNext", - "moduleResolution": "bundler", - "allowImportingTsExtensions": false, - "target": "ES2022", - "noImplicitAny": false, - "allowJs": true, - "verbatimModuleSyntax": true, - "declaration": true, - "strict": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, - "exactOptionalPropertyTypes": false, - "noFallthroughCasesInSwitch": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noPropertyAccessFromIndexSignature": false, - "noUncheckedIndexedAccess": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "isolatedModules": true, - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["./src/**/*.ts"], - "exclude": ["node_modules"] + "compilerOptions": { + "composite": false, + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "target": "ES2022", + "noImplicitAny": false, + "allowJs": true, + "verbatimModuleSyntax": true, + "declaration": true, + "strict": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": false, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "$bknd/*": ["../../app/src/*"] + } + }, + "include": ["./src/**/*.ts"], + "exclude": ["node_modules"] }