From 58c7aba1a4e3ffed550ad7c2c2f56caac5e97113 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 6 Jun 2025 16:52:07 +0200 Subject: [PATCH 1/4] postgres: added `pg` and `postgres`, and examples for xata and neon --- app/src/core/console.ts | 6 + app/src/core/utils/test.ts | 2 +- app/src/data/connection/Connection.ts | 21 ++- app/src/data/connection/index.ts | 1 + app/src/modules/ModuleManager.ts | 2 +- bun.lock | 65 +++++--- packages/postgres/README.md | 98 ++++++++--- packages/postgres/examples/neon.ts | 14 ++ packages/postgres/examples/xata.ts | 24 +++ packages/postgres/package.json | 13 +- packages/postgres/src/PgPostgresConnection.ts | 32 ++++ packages/postgres/src/PostgresConnection.ts | 45 +---- packages/postgres/src/PostgresJsConnection.ts | 41 +++++ packages/postgres/src/custom.ts | 43 +++++ packages/postgres/src/index.ts | 5 +- packages/postgres/test/base.test.ts | 19 --- packages/postgres/test/integration.test.ts | 113 ------------- packages/postgres/test/pg.test.ts | 16 ++ packages/postgres/test/postgresjs.test.ts | 16 ++ packages/postgres/test/setup.ts | 25 --- packages/postgres/test/suite.ts | 155 ++++++++++++++++++ 21 files changed, 509 insertions(+), 247 deletions(-) create mode 100644 packages/postgres/examples/neon.ts create mode 100644 packages/postgres/examples/xata.ts create mode 100644 packages/postgres/src/PgPostgresConnection.ts create mode 100644 packages/postgres/src/PostgresJsConnection.ts create mode 100644 packages/postgres/src/custom.ts delete mode 100644 packages/postgres/test/base.test.ts delete mode 100644 packages/postgres/test/integration.test.ts create mode 100644 packages/postgres/test/pg.test.ts create mode 100644 packages/postgres/test/postgresjs.test.ts delete mode 100644 packages/postgres/test/setup.ts create mode 100644 packages/postgres/test/suite.ts diff --git a/app/src/core/console.ts b/app/src/core/console.ts index daa7587..756d97b 100644 --- a/app/src/core/console.ts +++ b/app/src/core/console.ts @@ -23,6 +23,12 @@ function hasColors() { } const __consoles = { + critical: { + prefix: "CRT", + color: colors.red, + args_color: colors.red, + original: console.error, + }, error: { prefix: "ERR", color: colors.red, diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts index c022592..91ae2d3 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -36,7 +36,7 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn" severities.forEach((severity) => { console[severity] = () => null; }); - $console.setLevel("error"); + $console.setLevel("critical"); } export function enableConsoleLog() { diff --git a/app/src/data/connection/Connection.ts b/app/src/data/connection/Connection.ts index ce521e1..814c03d 100644 --- a/app/src/data/connection/Connection.ts +++ b/app/src/data/connection/Connection.ts @@ -2,6 +2,8 @@ import { type AliasableExpression, type ColumnBuilderCallback, type ColumnDataType, + type DatabaseIntrospector, + type Dialect, type Expression, type Kysely, type KyselyPlugin, @@ -12,7 +14,8 @@ import { type Simplify, sql, } from "kysely"; -import type { BaseIntrospector } from "./BaseIntrospector"; +import type { BaseIntrospector, BaseIntrospectorConfig } from "./BaseIntrospector"; +import type { Constructor } from "core"; export type QB = SelectQueryBuilder; @@ -159,3 +162,19 @@ export abstract class Connection { // no-op by default } } + +export function customIntrospector>( + dialect: T, + introspector: Constructor, + options: BaseIntrospectorConfig = {}, +) { + return { + create(...args: ConstructorParameters) { + return new (class extends dialect { + override createIntrospector(db: Kysely): DatabaseIntrospector { + return new introspector(db, options); + } + })(...args); + }, + }; +} diff --git a/app/src/data/connection/index.ts b/app/src/data/connection/index.ts index 2e745e0..ce8c7ff 100644 --- a/app/src/data/connection/index.ts +++ b/app/src/data/connection/index.ts @@ -5,6 +5,7 @@ export { type IndexSpec, type DbFunctions, type SchemaResponse, + customIntrospector, } from "./Connection"; // sqlite diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 8c1b554..022e314 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -183,7 +183,7 @@ export class ModuleManager { const context = this.ctx(true); for (const key in MODULES) { - const moduleConfig = key in initial ? initial[key] : {}; + const moduleConfig = initial && key in initial ? initial[key] : {}; const module = new MODULES[key](moduleConfig, context) as Module; module.setListener(async (c) => { await this.onModuleConfigUpdated(key, c); diff --git a/bun.lock b/bun.lock index 9744812..ddeb983 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "app": { "name": "bknd", - "version": "0.12.0", + "version": "0.13.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -149,19 +149,24 @@ }, "packages/postgres": { "name": "@bknd/postgres", - "version": "0.0.1", - "dependencies": { - "kysely": "^0.27.6", - "pg": "^8.14.0", - }, + "version": "0.1.0", "devDependencies": { "@types/bun": "^1.2.5", "@types/node": "^22.13.10", "@types/pg": "^8.11.11", + "@xata.io/client": "^0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", + "@xata.io/kysely": "^0.2.1", "bknd": "workspace:*", + "kysely-neon": "^1.3.0", "tsup": "^8.4.0", "typescript": "^5.8.2", }, + "optionalDependencies": { + "kysely": "^0.27.6", + "kysely-postgres-js": "^2.0.0", + "pg": "^8.14.0", + "postgres": "^3.4.7", + }, }, "packages/sqlocal": { "name": "@bknd/sqlocal", @@ -791,6 +796,8 @@ "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], + "@neondatabase/serverless": ["@neondatabase/serverless@0.4.26", "", { "dependencies": { "@types/pg": "8.6.6" } }, "sha512-6DYEKos2GYn8NTgcJf33BLAx//LcgqzHVavQWe6ZkaDqmEq0I0Xtub6pzwFdq9iayNdCj7e2b0QKr5a8QKB8kQ=="], + "@next/env": ["@next/env@15.2.1", "", {}, "sha512-JmY0qvnPuS2NCWOz2bbby3Pe0VzdAQ7XpEB6uLIHmtXNfAsAO0KLQLkuAoc42Bxbo3/jMC3dcn9cdf+piCcG2Q=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aWXT+5KEREoy3K5AKtiKwioeblmOvFFjd+F3dVleLvvLiQ/mD//jOOuUcx5hzcO9ISSw4lrqtUPntTpK32uXXQ=="], @@ -1395,6 +1402,10 @@ "@web3-storage/multipart-parser": ["@web3-storage/multipart-parser@1.0.0", "", {}, "sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw=="], + "@xata.io/client": ["@xata.io/client@0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", "", { "peerDependencies": { "typescript": ">=4.5" } }, "sha512-4Js4SAKwmmOPmZVIS1l2K8XVGGkUOi8L1jXuagDfeUX56n95wfA4xYMSmsVS0RLMmRWI4UM4bp5UcFJxwbFYGw=="], + + "@xata.io/kysely": ["@xata.io/kysely@0.2.1", "", { "dependencies": { "@xata.io/client": "0.30.1" }, "peerDependencies": { "kysely": "*" } }, "sha512-0+WBcFkBSNEu11wVTyJyeNMOPUuolDKJMjXQr1nheHTNZLfsL0qKshTZOKIC/bGInjepGA7DQ/HFeKDHe5CDpA=="], + "@xyflow/react": ["@xyflow/react@12.4.4", "", { "dependencies": { "@xyflow/system": "0.0.52", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-9RZ9dgKZNJOlbrXXST5HPb5TcXPOIDGondjwcjDro44OQRPl1E0ZRPTeWPGaQtVjbg4WpR4BUYwOeshNI2TuVg=="], "@xyflow/system": ["@xyflow/system@0.0.52", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-pJBMaoh/GEebIABWEIxAai0yf57dm+kH7J/Br+LnLFPuJL87Fhcmm4KFWd/bCUy/kCWUg+2/yFAGY0AUHRPOnQ=="], @@ -2543,6 +2554,10 @@ "kysely-d1": ["kysely-d1@0.3.0", "", { "peerDependencies": { "kysely": "*" } }, "sha512-9wTbE6ooLiYtBa4wPg9e4fjfcmvRtgE/2j9pAjYrIq+iz+EsH/Hj9YbtxpEXA6JoRgfulVQ1EtGj6aycGGRpYw=="], + "kysely-neon": ["kysely-neon@1.3.0", "", { "peerDependencies": { "@neondatabase/serverless": "^0.4.3", "kysely": "0.x.x", "ws": "^8.13.0" }, "optionalPeers": ["ws"] }, "sha512-CIIlbmqpIXVJDdBEYtEOwbmALag0jmqYrGfBeM4cHKb9AgBGs+X1SvXUZ8TqkDacQEqEZN2XtsDoUkcMIISjHw=="], + + "kysely-postgres-js": ["kysely-postgres-js@2.0.0", "", { "peerDependencies": { "kysely": ">= 0.24.0 < 1", "postgres": ">= 3.4.0 < 4" } }, "sha512-R1tWx6/x3tSatWvsmbHJxpBZYhNNxcnMw52QzZaHKg7ZOWtHib4iZyEaw4gb2hNKVctWQ3jfMxZT/ZaEMK6kBQ=="], + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], @@ -2905,7 +2920,7 @@ "pg-protocol": ["pg-protocol@1.8.0", "", {}, "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="], - "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], @@ -2961,13 +2976,15 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], - "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], + "postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], - "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], @@ -3847,8 +3864,6 @@ "@babel/runtime/regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], - "@bknd/postgres/@types/bun": ["@types/bun@1.2.5", "", { "dependencies": { "bun-types": "1.2.5" } }, "sha512-w2OZTzrZTVtbnJew1pdFmgV99H0/L+Pvw+z1P67HaR18MHOzYnTYOi6qzErhK8HyT+DB782ADVPPE92Xu2/Opg=="], - "@bundled-es-modules/cookie/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], @@ -3909,6 +3924,8 @@ "@libsql/kysely-libsql/@libsql/client": ["@libsql/client@0.8.1", "", { "dependencies": { "@libsql/core": "^0.8.1", "@libsql/hrana-client": "^0.6.2", "js-base64": "^3.7.5", "libsql": "^0.3.10", "promise-limit": "^2.7.0" } }, "sha512-xGg0F4iTDFpeBZ0r4pA6icGsYa5rG6RAG+i/iLDnpCAnSuTqEWMDdPlVseiq4Z/91lWI9jvvKKiKpovqJ1kZWA=="], + "@neondatabase/serverless/@types/pg": ["@types/pg@8.6.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-O2xNmXebtwVekJDD+02udOncjVcMZQuTEQEMpKJ0ZRf5E7/9JJX3izhKUcUifBkyKpljyUM6BTgy2trmviKlpw=="], + "@plasmicapp/query/swr": ["swr@1.3.0", "", { "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0" } }, "sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw=="], "@remix-run/node/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -4065,6 +4082,8 @@ "@types/jest/pretty-format": ["pretty-format@25.5.0", "", { "dependencies": { "@jest/types": "^25.5.0", "ansi-regex": "^5.0.0", "ansi-styles": "^4.0.0", "react-is": "^16.12.0" } }, "sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ=="], + "@types/pg/pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], + "@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], "@typescript-eslint/typescript-estree/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -4125,6 +4144,8 @@ "@wdio/utils/decamelize": ["decamelize@6.0.0", "", {}, "sha512-Fv96DCsdOgB6mdGl67MT5JaTNKRzrzill5OH5s8bjYJXVlcXyPYGyPsUkWyGV5p1TXI5esYIYMMeDJL0hEIwaA=="], + "@xata.io/kysely/@xata.io/client": ["@xata.io/client@0.30.1", "", { "peerDependencies": { "typescript": ">=4.5" } }, "sha512-dAzDPHmIfenVIpF39m1elmW5ngjWu2mO8ZqJBN7dmYdXr98uhPANfLdVZnc3mUNG+NH37LqY1dSO862hIo2oRw=="], + "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "acorn-globals/acorn": ["acorn@6.4.2", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ=="], @@ -4467,8 +4488,6 @@ "peek-stream/duplexify": ["duplexify@3.7.1", "", { "dependencies": { "end-of-stream": "^1.0.0", "inherits": "^2.0.1", "readable-stream": "^2.0.0", "stream-shift": "^1.0.0" } }, "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g=="], - "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -4741,6 +4760,14 @@ "@types/jest/pretty-format/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "@types/pg/pg-types/postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "@types/pg/pg-types/postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], + + "@types/pg/pg-types/postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], + + "@types/pg/pg-types/postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], + "@typescript-eslint/typescript-estree/glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "@verdaccio/local-storage-legacy/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], @@ -4929,14 +4956,6 @@ "peek-stream/duplexify/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - - "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], - - "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], - - "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "progress-estimator/chalk/ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="], "progress-estimator/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], diff --git a/packages/postgres/README.md b/packages/postgres/README.md index e04d8a1..b66395b 100644 --- a/packages/postgres/README.md +++ b/packages/postgres/README.md @@ -1,5 +1,8 @@ # Postgres adapter for `bknd` (experimental) -This packages adds an adapter to use a Postgres database with [`bknd`](https://github.com/bknd-io/bknd). It is based on [`pg`](https://github.com/brianc/node-postgres) and the driver included in [`kysely`](https://github.com/kysely-org/kysely). +This packages adds an adapter to use a Postgres database with [`bknd`](https://github.com/bknd-io/bknd). It works with both `pg` and `postgres` drivers, and supports custom postgres connections. +* works with any Postgres database (tested with Supabase, Neon, Xata, and RDS) +* choose between `pg` and `postgres` drivers +* create custom postgres connections with any kysely postgres dialect ## Installation Install the adapter with: @@ -7,44 +10,93 @@ Install the adapter with: npm install @bknd/postgres ``` -## Usage +## Using `pg` driver +Install the [`pg`](https://github.com/brianc/node-postgres) driver with: +```bash +npm install pg +``` + Create a connection: ```ts -import { PostgresConnection } from "@bknd/postgres"; +import { pg } from "@bknd/postgres"; -const connection = new PostgresConnection({ +// accepts `pg` configuration +const connection = pg({ host: "localhost", port: 5432, user: "postgres", password: "postgres", - database: "bknd", + database: "postgres", +}); + +// or with a connection string +const connection = pg({ + connectionString: "postgres://postgres:postgres@localhost:5432/postgres", }); ``` -Use the connection depending on which framework or runtime you are using. E.g., when using `createApp`, you can use the connection as follows: +## Using `postgres` driver -```ts -import { createApp } from "bknd"; -import { PostgresConnection } from "@bknd/postgres"; - -const connection = new PostgresConnection(); -const app = createApp({ connection }); +Install the [`postgres`](https://github.com/porsager/postgres) driver with: +```bash +npm install postgres ``` -Or if you're using it with a framework, say Next.js, you can add the connection object to where you're initializating the app: +Create a connection: ```ts -// e.g. in src/app/api/[[...bknd]]/route.ts -import { serve } from "bknd/adapter/nextjs"; -import { PostgresConnection } from "@bknd/postgres"; +import { postgresJs } from "@bknd/postgres"; -const connection = new PostgresConnection(); -const handler = serve({ - connection -}) - -// ... +// accepts `postgres` configuration +const connection = postgresJs("postgres://postgres:postgres@localhost:5432/postgres"); ``` -For more information about how to integrate Next.js in general, check out the [Next.js documentation](https://docs.bknd.io/integration/nextjs). \ No newline at end of file +## Using custom postgres dialects + +You can create a custom kysely postgres dialect by using the `createCustomPostgresConnection` function. + +```ts +import { createCustomPostgresConnection } from "@bknd/postgres"; + +const connection = createCustomPostgresConnection(MyDialect)({ + // your custom dialect configuration + supports: { + batching: true + }, + excludeTables: ["my_table"], + plugins: [new MyKyselyPlugin()], +}); +``` + +### Custom `neon` connection + +```typescript +import { createCustomPostgresConnection } from "@bknd/postgres"; +import { NeonDialect } from "kysely-neon"; + +const connection = createCustomPostgresConnection(NeonDialect)({ + connectionString: process.env.NEON, +}); +``` + +### Custom `xata` connection + +```typescript +import { createCustomPostgresConnection } from "@bknd/postgres"; +import { XataDialect } from "@xata.io/kysely"; +import { buildClient } from "@xata.io/client"; + +const client = buildClient(); +const xata = new client({ + databaseURL: process.env.XATA_URL, + apiKey: process.env.XATA_API_KEY, + branch: process.env.XATA_BRANCH, +}); + +const connection = createCustomPostgresConnection(XataDialect, { + supports: { + batching: false, + }, +})({ xata }); +``` \ No newline at end of file diff --git a/packages/postgres/examples/neon.ts b/packages/postgres/examples/neon.ts new file mode 100644 index 0000000..f4efd02 --- /dev/null +++ b/packages/postgres/examples/neon.ts @@ -0,0 +1,14 @@ +import { serve } from "bknd/adapter/bun"; +import { createCustomPostgresConnection } from "../src"; +import { NeonDialect } from "kysely-neon"; + +const neon = createCustomPostgresConnection(NeonDialect); + +export default serve({ + connection: neon({ + connectionString: process.env.NEON, + }), + // ignore this, it's only required within this repository + // because bknd is installed via "workspace:*" + distPath: "../../app/dist", +}); diff --git a/packages/postgres/examples/xata.ts b/packages/postgres/examples/xata.ts new file mode 100644 index 0000000..75e2a32 --- /dev/null +++ b/packages/postgres/examples/xata.ts @@ -0,0 +1,24 @@ +import { serve } from "bknd/adapter/bun"; +import { createCustomPostgresConnection } from "../src"; +import { XataDialect } from "@xata.io/kysely"; +import { buildClient } from "@xata.io/client"; + +const client = buildClient(); +const xata = new client({ + databaseURL: process.env.XATA_URL, + apiKey: process.env.XATA_API_KEY, + branch: process.env.XATA_BRANCH, +}); + +const connection = createCustomPostgresConnection(XataDialect, { + supports: { + batching: false, + }, +})({ xata }); + +export default serve({ + connection, + // ignore this, it's only required within this repository + // because bknd is installed via "workspace:*" + distPath: "../../../app/dist", +}); diff --git a/packages/postgres/package.json b/packages/postgres/package.json index 681fffe..cf8a678 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -1,6 +1,6 @@ { "name": "@bknd/postgres", - "version": "0.0.1", + "version": "0.1.0", "type": "module", "main": "dist/index.js", "module": "dist/index.js", @@ -17,15 +17,20 @@ "docker:start": "docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=bknd -p 5430:5432 postgres:17", "docker:stop": "docker stop bknd-test-postgres" }, - "dependencies": { + "optionalDependencies": { + "kysely": "^0.27.6", + "kysely-postgres-js": "^2.0.0", "pg": "^8.14.0", - "kysely": "^0.27.6" + "postgres": "^3.4.7" }, "devDependencies": { "@types/bun": "^1.2.5", "@types/node": "^22.13.10", "@types/pg": "^8.11.11", + "@xata.io/client": "^0.0.0-next.v93343b9646f57a1e5c51c35eccf0767c2bb80baa", + "@xata.io/kysely": "^0.2.1", "bknd": "workspace:*", + "kysely-neon": "^1.3.0", "tsup": "^8.4.0", "typescript": "^5.8.2" }, @@ -36,7 +41,7 @@ "clean": true, "minify": true, "dts": true, - "external": ["bknd", "pg", "kysely"] + "external": ["bknd", "pg", "kysely", "kysely-postgres-js"] }, "files": ["dist", "README.md", "!*.map", "!metafile*.json"] } diff --git a/packages/postgres/src/PgPostgresConnection.ts b/packages/postgres/src/PgPostgresConnection.ts new file mode 100644 index 0000000..85a5c84 --- /dev/null +++ b/packages/postgres/src/PgPostgresConnection.ts @@ -0,0 +1,32 @@ +import { Kysely, PostgresDialect } from "kysely"; +import { PostgresIntrospector } from "./PostgresIntrospector"; +import { PostgresConnection, plugins } from "./PostgresConnection"; +import { customIntrospector } from "bknd/data"; +import $pg from "pg"; + +export type PgPostgresConnectionConfig = $pg.PoolConfig; + +export class PgPostgresConnection extends PostgresConnection { + private pool: $pg.Pool; + + constructor(config: PgPostgresConnectionConfig) { + const pool = new $pg.Pool(config); + const kysely = new Kysely({ + dialect: customIntrospector(PostgresDialect, PostgresIntrospector, { + excludeTables: [], + }).create({ pool }), + plugins, + }); + + super(kysely); + this.pool = pool; + } + + override async close(): Promise { + await this.pool.end(); + } +} + +export function pg(config: PgPostgresConnectionConfig): PgPostgresConnection { + return new PgPostgresConnection(config); +} diff --git a/packages/postgres/src/PostgresConnection.ts b/packages/postgres/src/PostgresConnection.ts index c1495d5..db219fd 100644 --- a/packages/postgres/src/PostgresConnection.ts +++ b/packages/postgres/src/PostgresConnection.ts @@ -1,56 +1,33 @@ -import { Connection, type FieldSpec, type SchemaResponse } from "bknd/data"; +import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "bknd/data"; import { + ParseJSONResultsPlugin, type ColumnDataType, type ColumnDefinitionBuilder, - type DatabaseIntrospector, - Kysely, - ParseJSONResultsPlugin, - PostgresDialect, + type Kysely, + type KyselyPlugin, type SelectQueryBuilder, } from "kysely"; import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/postgres"; -import pg from "pg"; -import { PostgresIntrospector } from "./PostgresIntrospector"; -export type PostgresConnectionConfig = pg.PoolConfig; export type QB = SelectQueryBuilder; -const plugins = [new ParseJSONResultsPlugin()]; +export const plugins = [new ParseJSONResultsPlugin()]; -class CustomPostgresDialect extends PostgresDialect { - override createIntrospector(db: Kysely): DatabaseIntrospector { - return new PostgresIntrospector(db, { - excludeTables: [], - }); - } -} - -export class PostgresConnection extends Connection { +export abstract class PostgresConnection extends Connection { protected override readonly supported = { batching: true, }; - private pool: pg.Pool; - - constructor(config: PostgresConnectionConfig) { - const pool = new pg.Pool(config); - const kysely = new Kysely({ - dialect: new CustomPostgresDialect({ - pool, - }), - plugins, - //log: ["query", "error"], - }); + constructor(kysely: Kysely, fn?: Partial, _plugins?: KyselyPlugin[]) { super( kysely, - { + fn ?? { jsonArrayFrom, jsonBuildObject, jsonObjectFrom, }, - plugins, + _plugins ?? plugins, ); - this.pool = pool; } override getFieldSchema(spec: FieldSpec): SchemaResponse { @@ -90,10 +67,6 @@ export class PostgresConnection extends Connection { ]; } - override async close(): Promise { - await this.pool.end(); - } - protected override async batch( queries: [...Queries], ): Promise<{ diff --git a/packages/postgres/src/PostgresJsConnection.ts b/packages/postgres/src/PostgresJsConnection.ts new file mode 100644 index 0000000..1ab1fe4 --- /dev/null +++ b/packages/postgres/src/PostgresJsConnection.ts @@ -0,0 +1,41 @@ +import { Kysely } from "kysely"; +import { PostgresIntrospector } from "./PostgresIntrospector"; +import { PostgresConnection, plugins } from "./PostgresConnection"; +import { customIntrospector } from "bknd/data"; +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 { + private postgres: Sql; + + constructor(opts: { postgres: Sql }) { + const kysely = new Kysely({ + dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, { + excludeTables: [], + }).create({ postgres: opts.postgres }), + plugins, + }); + + super(kysely); + this.postgres = opts.postgres; + } + + override async close(): Promise { + await this.postgres.end(); + } +} + +export function postgresJs( + connectionString: string, + config?: PostgresJsConfig, +): PostgresJsConnection; +export function postgresJs(config: PostgresJsConfig): PostgresJsConnection; +export function postgresJs( + first: PostgresJsConfig | string, + second?: PostgresJsConfig, +): PostgresJsConnection { + const postgres = typeof first === "string" ? $postgresJs(first, second) : $postgresJs(first); + return new PostgresJsConnection({ postgres }); +} diff --git a/packages/postgres/src/custom.ts b/packages/postgres/src/custom.ts new file mode 100644 index 0000000..b7369f1 --- /dev/null +++ b/packages/postgres/src/custom.ts @@ -0,0 +1,43 @@ +import type { Constructor } from "bknd/core"; +import { customIntrospector, type DbFunctions } from "bknd/data"; +import { Kysely, type Dialect, type KyselyPlugin } from "kysely"; +import { plugins, PostgresConnection } from "./PostgresConnection"; +import { PostgresIntrospector } from "./PostgresIntrospector"; + +export type CustomPostgresConnection = { + supports?: PostgresConnection["supported"]; + fn?: Partial; + plugins?: KyselyPlugin[]; + excludeTables?: string[]; +}; + +export function createCustomPostgresConnection< + T extends Constructor, + C extends ConstructorParameters[0], +>( + dialect: Constructor, + options?: CustomPostgresConnection, +): (config: C) => PostgresConnection { + const supported = { + batching: true, + ...((options?.supports ?? {}) as any), + }; + + return (config: C) => + new (class extends PostgresConnection { + protected override readonly supported = supported; + + constructor(config: C) { + super( + new Kysely({ + dialect: customIntrospector(dialect, PostgresIntrospector, { + excludeTables: options?.excludeTables ?? [], + }).create(config), + plugins: options?.plugins ?? plugins, + }), + options?.fn, + options?.plugins, + ); + } + })(config); +} diff --git a/packages/postgres/src/index.ts b/packages/postgres/src/index.ts index ef7c56f..011270e 100644 --- a/packages/postgres/src/index.ts +++ b/packages/postgres/src/index.ts @@ -1,2 +1,5 @@ -export { PostgresConnection, type PostgresConnectionConfig } from "./PostgresConnection"; +export { pg, PgPostgresConnection, type PgPostgresConnectionConfig } from "./PgPostgresConnection"; export { PostgresIntrospector } from "./PostgresIntrospector"; +export { PostgresConnection, type QB, plugins } from "./PostgresConnection"; +export { postgresJs, PostgresJsConnection, type PostgresJsConfig } from "./PostgresJsConnection"; +export { createCustomPostgresConnection } from "./custom"; diff --git a/packages/postgres/test/base.test.ts b/packages/postgres/test/base.test.ts deleted file mode 100644 index c5adbda..0000000 --- a/packages/postgres/test/base.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it, expect } from "bun:test"; - -import { PostgresConnection } from "../src"; -import { createConnection, cleanDatabase } from "./setup"; - -describe(PostgresConnection, () => { - it("should connect to the database", async () => { - const connection = createConnection(); - expect(await connection.ping()).toBe(true); - }); - - it("should clean the database", async () => { - const connection = createConnection(); - await cleanDatabase(connection); - - const tables = await connection.getIntrospector().getTables(); - expect(tables).toEqual([]); - }); -}); diff --git a/packages/postgres/test/integration.test.ts b/packages/postgres/test/integration.test.ts deleted file mode 100644 index 90b8746..0000000 --- a/packages/postgres/test/integration.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from "bun:test"; - -import { createApp } from "bknd"; -import * as proto from "bknd/data"; - -import { createConnection, cleanDatabase } from "./setup"; -import type { PostgresConnection } from "../src"; - -let connection: PostgresConnection; -beforeAll(async () => { - connection = createConnection(); - await cleanDatabase(connection); -}); - -afterEach(async () => { - await cleanDatabase(connection); -}); - -afterAll(async () => { - await connection.close(); -}); - -describe("integration", () => { - it("should create app and ping", async () => { - const app = createApp({ - connection, - }); - await app.build(); - - expect(app.version()).toBeDefined(); - expect(await app.em.ping()).toBe(true); - }); - - it("should create a basic schema", async () => { - const schema = proto.em( - { - posts: proto.entity("posts", { - title: proto.text().required(), - content: proto.text(), - }), - comments: proto.entity("comments", { - content: proto.text(), - }), - }, - (fns, s) => { - fns.relation(s.comments).manyToOne(s.posts); - fns.index(s.posts).on(["title"], true); - }, - ); - - const app = createApp({ - connection, - initialConfig: { - data: schema.toJSON(), - }, - }); - - await app.build(); - - expect(app.em.entities.length).toBe(2); - expect(app.em.entities.map((e) => e.name)).toEqual(["posts", "comments"]); - - const api = app.getApi(); - - expect( - ( - await api.data.createMany("posts", [ - { - title: "Hello", - content: "World", - }, - { - title: "Hello 2", - content: "World 2", - }, - ]) - ).data, - ).toEqual([ - { - id: 1, - title: "Hello", - content: "World", - }, - { - id: 2, - title: "Hello 2", - content: "World 2", - }, - ] as any); - - // try to create an existing - expect( - ( - await api.data.createOne("posts", { - title: "Hello", - }) - ).ok, - ).toBe(false); - - // add a comment to a post - await api.data.createOne("comments", { - content: "Hello", - posts_id: 1, - }); - - // and then query using a `with` property - const result = await api.data.readMany("posts", { with: ["comments"] }); - expect(result.length).toBe(2); - expect(result[0].comments.length).toBe(1); - expect(result[0].comments[0].content).toBe("Hello"); - expect(result[1].comments.length).toBe(0); - }); -}); diff --git a/packages/postgres/test/pg.test.ts b/packages/postgres/test/pg.test.ts new file mode 100644 index 0000000..c6ac89a --- /dev/null +++ b/packages/postgres/test/pg.test.ts @@ -0,0 +1,16 @@ +import { describe } from "bun:test"; +import { pg } from "../src/PgPostgresConnection"; +import { testSuite } from "./suite"; + +describe("pg", () => { + testSuite({ + createConnection: () => + pg({ + host: "localhost", + port: 5430, + user: "postgres", + password: "postgres", + database: "bknd", + }), + }); +}); diff --git a/packages/postgres/test/postgresjs.test.ts b/packages/postgres/test/postgresjs.test.ts new file mode 100644 index 0000000..5a1f5a4 --- /dev/null +++ b/packages/postgres/test/postgresjs.test.ts @@ -0,0 +1,16 @@ +import { describe } from "bun:test"; +import { postgresJs } from "../src/PostgresJsConnection"; +import { testSuite } from "./suite"; + +describe("postgresjs", () => { + testSuite({ + createConnection: () => + postgresJs({ + host: "localhost", + port: 5430, + user: "postgres", + password: "postgres", + database: "bknd", + }), + }); +}); diff --git a/packages/postgres/test/setup.ts b/packages/postgres/test/setup.ts deleted file mode 100644 index d82427d..0000000 --- a/packages/postgres/test/setup.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Kysely } from "kysely"; -import { PostgresConnection, PostgresIntrospector, type PostgresConnectionConfig } from "../src"; - -export const info = { - host: "localhost", - port: 5430, - user: "postgres", - password: "postgres", - database: "bknd", -}; - -export function createConnection(config: PostgresConnectionConfig = {}) { - return new PostgresConnection({ - ...info, - ...config, - }); -} - -export async function cleanDatabase(connection: PostgresConnection) { - const kysely = connection.kysely; - - // drop all tables & create new schema - await kysely.schema.dropSchema("public").ifExists().cascade().execute(); - await kysely.schema.createSchema("public").execute(); -} diff --git a/packages/postgres/test/suite.ts b/packages/postgres/test/suite.ts new file mode 100644 index 0000000..bf53337 --- /dev/null +++ b/packages/postgres/test/suite.ts @@ -0,0 +1,155 @@ +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 { disableConsoleLog, enableConsoleLog } from "bknd/utils"; + +export type TestSuiteConfig = { + createConnection: () => InstanceType; + cleanDatabase?: (connection: InstanceType) => Promise; +}; + +export async function defaultCleanDatabase(connection: InstanceType) { + const kysely = connection.kysely; + + // drop all tables & create new schema + await kysely.schema.dropSchema("public").ifExists().cascade().execute(); + await kysely.schema.createSchema("public").execute(); +} + +async function cleanDatabase( + connection: InstanceType, + config: TestSuiteConfig, +) { + if (config.cleanDatabase) { + await config.cleanDatabase(connection); + } else { + await defaultCleanDatabase(connection); + } +} + +export function testSuite(config: TestSuiteConfig) { + beforeAll(() => disableConsoleLog(["log", "warn", "error"])); + afterAll(() => enableConsoleLog()); + + describe("base", () => { + it("should connect to the database", async () => { + const connection = config.createConnection(); + expect(await connection.ping()).toBe(true); + }); + + it("should clean the database", async () => { + const connection = config.createConnection(); + await cleanDatabase(connection, config); + + const tables = await connection.getIntrospector().getTables(); + expect(tables).toEqual([]); + }); + }); + + describe("integration", () => { + let connection: PostgresConnection; + beforeAll(async () => { + connection = config.createConnection(); + await cleanDatabase(connection, config); + }); + + afterEach(async () => { + await cleanDatabase(connection, config); + }); + + afterAll(async () => { + await connection.close(); + }); + + it("should create app and ping", async () => { + const app = createApp({ + connection, + }); + await app.build(); + + expect(app.version()).toBeDefined(); + expect(await app.em.ping()).toBe(true); + }); + + it("should create a basic schema", async () => { + const schema = proto.em( + { + posts: proto.entity("posts", { + title: proto.text().required(), + content: proto.text(), + }), + comments: proto.entity("comments", { + content: proto.text(), + }), + }, + (fns, s) => { + fns.relation(s.comments).manyToOne(s.posts); + fns.index(s.posts).on(["title"], true); + }, + ); + + const app = createApp({ + connection, + initialConfig: { + data: schema.toJSON(), + }, + }); + + await app.build(); + + expect(app.em.entities.length).toBe(2); + expect(app.em.entities.map((e) => e.name)).toEqual(["posts", "comments"]); + + const api = app.getApi(); + + expect( + ( + await api.data.createMany("posts", [ + { + title: "Hello", + content: "World", + }, + { + title: "Hello 2", + content: "World 2", + }, + ]) + ).data, + ).toEqual([ + { + id: 1, + title: "Hello", + content: "World", + }, + { + id: 2, + title: "Hello 2", + content: "World 2", + }, + ] as any); + + // try to create an existing + expect( + ( + await api.data.createOne("posts", { + title: "Hello", + }) + ).ok, + ).toBe(false); + + // add a comment to a post + await api.data.createOne("comments", { + content: "Hello", + posts_id: 1, + }); + + // and then query using a `with` property + const result = await api.data.readMany("posts", { with: ["comments"] }); + expect(result.length).toBe(2); + expect(result[0].comments.length).toBe(1); + expect(result[0].comments[0].content).toBe("Hello"); + expect(result[1].comments.length).toBe(0); + }); + }); +} From e66e05b2b0393628cc687c82d145907cc8b186b6 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 7 Jun 2025 09:59:50 +0200 Subject: [PATCH 2/4] postgres: make sure to store id as varchar if uuid --- bun.lock | 4 +- packages/postgres/src/PostgresConnection.ts | 8 +++- packages/postgres/test/suite.ts | 42 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 642fcde..b9484d7 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "app": { "name": "bknd", - "version": "0.13.1-rc.0", + "version": "0.14.0-rc.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -45,6 +45,7 @@ "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", + "hono": "^4.7.11", "json-schema-form-react": "^0.0.2", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", @@ -82,7 +83,6 @@ "autoprefixer": "^10.4.21", "clsx": "^2.1.1", "dotenv": "^16.4.7", - "hono": "4.7.11", "jotai": "^2.12.2", "jsdom": "^26.0.0", "jsonv-ts": "^0.1.0", diff --git a/packages/postgres/src/PostgresConnection.ts b/packages/postgres/src/PostgresConnection.ts index db219fd..9ea1d2c 100644 --- a/packages/postgres/src/PostgresConnection.ts +++ b/packages/postgres/src/PostgresConnection.ts @@ -32,7 +32,13 @@ export abstract class PostgresConnection extends Connection { override getFieldSchema(spec: FieldSpec): SchemaResponse { this.validateFieldSpecType(spec.type); - let type: ColumnDataType = spec.primary ? "serial" : spec.type; + let type: ColumnDataType = spec.type; + + if (spec.primary) { + if (spec.type === "integer") { + type = "serial"; + } + } switch (spec.type) { case "blob": diff --git a/packages/postgres/test/suite.ts b/packages/postgres/test/suite.ts index bf53337..0d4feec 100644 --- a/packages/postgres/test/suite.ts +++ b/packages/postgres/test/suite.ts @@ -151,5 +151,47 @@ export function testSuite(config: TestSuiteConfig) { expect(result[0].comments[0].content).toBe("Hello"); expect(result[1].comments.length).toBe(0); }); + + it("should support uuid", async () => { + const schema = proto.em( + { + posts: proto.entity( + "posts", + { + title: proto.text().required(), + content: proto.text(), + }, + { + primary_format: "uuid", + }, + ), + comments: proto.entity("comments", { + content: proto.text(), + }), + }, + (fns, s) => { + fns.relation(s.comments).manyToOne(s.posts); + fns.index(s.posts).on(["title"], true); + }, + ); + + const app = createApp({ + connection, + initialConfig: { + data: schema.toJSON(), + }, + }); + + await app.build(); + const config = app.toJSON(); + // @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 data = await mutator.insertOne({ title: "Hello", content: "World" }); + expect(data.data.id).toBeString(); + expect(String(data.data.id).length).toBe(36); + }); }); } From 7bff84d601485dc2f113e87e910b020bccf2dcd2 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 7 Jun 2025 10:31:22 +0200 Subject: [PATCH 3/4] updated postgres package build --- app/src/data/api/DataController.ts | 1 - app/src/ui/routes/data/forms/entity.fields.form.tsx | 2 +- packages/postgres/package.json | 3 ++- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index e0f6c03..bc86a8d 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -344,7 +344,6 @@ export class DataController extends Controller { if (!this.entityExists(entity)) { return this.notFound(c); } - console.log("id", id); const options = c.req.valid("query") as RepoQuery; const result = await this.em.repository(entity).findId(id, options); diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index b0186a6..a70b787 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -391,7 +391,7 @@ function EntityField({ allowDeselect={false} control={control} size="xs" - className="w-20" + className="w-22" /> ) : ( diff --git a/packages/postgres/package.json b/packages/postgres/package.json index cf8a678..6d77c10 100644 --- a/packages/postgres/package.json +++ b/packages/postgres/package.json @@ -38,10 +38,11 @@ "entry": ["src/index.ts"], "format": ["esm"], "target": "es2022", + "metafile": true, "clean": true, "minify": true, "dts": true, - "external": ["bknd", "pg", "kysely", "kysely-postgres-js"] + "external": ["bknd", "pg", "postgres", "kysely", "kysely-postgres-js"] }, "files": ["dist", "README.md", "!*.map", "!metafile*.json"] } From 6e08f458572c95ec434e8adfc90c842f03b7e260 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 7 Jun 2025 11:11:42 +0200 Subject: [PATCH 4/4] updated docs on new postgres instructions --- docs/usage/database.mdx | 101 +++++++++++++++++++++++++++++++++------- 1 file changed, 84 insertions(+), 17 deletions(-) diff --git a/docs/usage/database.mdx b/docs/usage/database.mdx index 0586572..3e677ce 100644 --- a/docs/usage/database.mdx +++ b/docs/usage/database.mdx @@ -3,13 +3,8 @@ title: 'Database' description: 'Choosing the right database configuration' --- -In order to use **bknd**, you need to prepare access information to your database and install -the dependencies. +In order to use **bknd**, you need to prepare access information to your database and install the dependencies. Connections to the database are managed using Kysely. Therefore, all [its dialects](https://kysely.dev/docs/dialects) are theoretically supported. - - Connections to the database are managed using Kysely. Therefore, all its dialects are - theoretically supported. However, only the `SQLite` dialect is implemented as of now. - ## Database ### SQLite in-memory @@ -56,7 +51,9 @@ connection object to your new database: ``` ### Cloudflare D1 -Using the [Cloudflare Adapter](/integration/cloudflare), you can choose to use a D1 database binding. To do so, you only need to add a D1 database to your `wrangler.toml` and it'll pick up automatically. To manually specify which D1 database to take, you can specify it manually: +Using the [Cloudflare Adapter](/integration/cloudflare), you can choose to use a D1 database binding. To do so, you only need to add a D1 database to your `wrangler.toml` and it'll pick up automatically. + +To manually specify which D1 database to take, you can specify it explicitly: ```ts import { serve, d1 } from "bknd/adapter/cloudflare"; @@ -73,17 +70,19 @@ To use bknd with Postgres, you need to install the `@bknd/postgres` package. You npm install @bknd/postgres ``` -This package uses `pg` under the hood. If you'd like to see `postgres` or any other flavor, please create an [issue on Github](https://github.com/bknd-io/bknd/issues/new). +You can connect to your Postgres database using `pg` or `postgres` dialects. Additionally, you may also define your custom connection. -To establish a connection to your database, you can use any connection options available on the [`pg`](https://node-postgres.com/apis/client) package. Here is a quick example using the [Node.js Adapter](http://localhost:3000/integration/node): +#### Using `pg` + +To establish a connection to your database, you can use any connection options available on the [`pg`](https://node-postgres.com/apis/client) package. ```js import { serve } from "bknd/adapter/node"; -import { PostgresConnection } from "@bknd/postgres"; +import { pg } from "@bknd/postgres"; /** @type {import("bknd/adapter/node").NodeBkndConfig} */ const config = { - connection: new PostgresConnection({ + connection: pg({ connectionString: "postgresql://user:password@localhost:5432/database", }), @@ -92,6 +91,64 @@ const config = { serve(config); ``` +#### Using `postgres` + +To establish a connection to your database, you can use any connection options available on the [`postgres`](https://github.com/porsager/postgres) package. + +```js +import { serve } from "bknd/adapter/node"; +import { postgresJs } from "@bknd/postgres"; + +serve({ + connection: postgresJs("postgresql://user:password@localhost:5432/database"), +}); +``` + +#### Using custom connection + +Several Postgres hosting providers offer their own clients to connect to their database, e.g. suitable for serverless environments. + +Example using `@neondatabase/serverless`: + +```js +import { createCustomPostgresConnection } from "@bknd/postgres"; +import { NeonDialect } from "kysely-neon"; + +const connection = createCustomPostgresConnection(NeonDialect)({ + connectionString: process.env.NEON, +}); + +serve({ + connection: connection, +}); +``` + +Example using `@xata.io/client`: + +```js +import { createCustomPostgresConnection } from "@bknd/postgres"; +import { XataDialect } from "@xata.io/kysely"; +import { buildClient } from "@xata.io/client"; + +const client = buildClient(); +const xata = new client({ + databaseURL: process.env.XATA_URL, + apiKey: process.env.XATA_API_KEY, + branch: process.env.XATA_BRANCH, +}); + +const connection = createCustomPostgresConnection(XataDialect, { + supports: { + batching: false, + }, +})({ xata }); + +serve({ + connection: connection, +}); +``` + + ### SQLocal To use bknd with `sqlocal` for a offline expierence, you need to install the `@bknd/sqlocal` package. You can do so by running the following command: @@ -138,7 +195,11 @@ const app = createApp({ connection }) ## Initial Structure To provide an initial database structure, you can pass `initialConfig` to the creation of an app. This will only be used if there isn't an existing configuration found in the database given. Here is a quick example: -```ts + + The initial structure is only respected if the database is empty! If you made updates, ensure to delete the database first, or perform updates through the Admin UI. + + +```typescript import { createApp } from "bknd"; import { em, entity, text, number } from "bknd/data"; @@ -193,13 +254,17 @@ Note that we didn't add relational fields directly to the entity, but instead de ### Type completion +To get type completion, there are two options: +1. Use the CLI to [generate the types](/usage/cli#generating-types-types) +2. If you have an initial structure created with the prototype functions, you can extend the `DB` interface with your own schema. + All entity related functions use the types defined in `DB` from `bknd/core`. To get type completion, you can extend that interface with your own schema: -```ts +```typescript import { em } from "bknd/data"; import { Api } from "bknd/client"; - const schema = em({ /* ... */ }); +const schema = em({ /* ... */ }); type Database = (typeof schema)["DB"]; declare module "bknd/core" { @@ -217,10 +282,12 @@ The type completion is available for the API as well as all provided [React hook To seed your database with initial data, you can pass a `seed` function to the configuration. It provides the `ModuleBuildContext` as the first argument. -Note that the seed function will only be executed on app's first boot. If a configuration -already exists in the database, it will not be executed. + + Note that the seed function will only be executed on app's first boot. If a configuration + already exists in the database, it will not be executed. + -```ts +```typescript import { createApp, type ModuleBuildContext } from "bknd"; const app = createApp({