Merge remote-tracking branch 'origin/release/0.20' into feat/opfs-and-sqlocal

This commit is contained in:
dswbx
2025-12-02 10:31:39 +01:00
87 changed files with 2060 additions and 824 deletions

View File

@@ -1,102 +0,0 @@
# Postgres adapter for `bknd` (experimental)
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:
```bash
npm install @bknd/postgres
```
## Using `pg` driver
Install the [`pg`](https://github.com/brianc/node-postgres) driver with:
```bash
npm install pg
```
Create a connection:
```ts
import { pg } from "@bknd/postgres";
// accepts `pg` configuration
const connection = pg({
host: "localhost",
port: 5432,
user: "postgres",
password: "postgres",
database: "postgres",
});
// or with a connection string
const connection = pg({
connectionString: "postgres://postgres:postgres@localhost:5432/postgres",
});
```
## Using `postgres` driver
Install the [`postgres`](https://github.com/porsager/postgres) driver with:
```bash
npm install postgres
```
Create a connection:
```ts
import { postgresJs } from "@bknd/postgres";
// accepts `postgres` configuration
const connection = postgresJs("postgres://postgres:postgres@localhost:5432/postgres");
```
## 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("my_postgres_dialect", 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("neon", 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("xata", XataDialect, {
supports: {
batching: false,
},
})({ xata });
```

View File

@@ -1,14 +0,0 @@
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",
});

View File

@@ -1,24 +0,0 @@
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",
});

View File

@@ -1,47 +0,0 @@
{
"name": "@bknd/postgres",
"version": "0.2.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsup",
"test": "bun test",
"typecheck": "tsc --noEmit",
"updater": "bun x npm-check-updates -ui",
"prepublishOnly": "bun run typecheck && bun run test && bun run build",
"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"
},
"optionalDependencies": {
"kysely": "^0.27.6",
"kysely-postgres-js": "^2.0.0",
"pg": "^8.14.0",
"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"
},
"tsup": {
"entry": ["src/index.ts"],
"format": ["esm"],
"target": "es2022",
"metafile": true,
"clean": true,
"minify": true,
"dts": true,
"external": ["bknd", "pg", "postgres", "kysely", "kysely-postgres-js"]
},
"files": ["dist", "README.md", "!*.map", "!metafile*.json"]
}

View File

@@ -1,33 +0,0 @@
import { Kysely, PostgresDialect } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
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) {
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<void> {
await this.pool.end();
}
}
export function pg(config: PgPostgresConnectionConfig): PgPostgresConnection {
return new PgPostgresConnection(config);
}

View File

@@ -1,89 +0,0 @@
import {
Connection,
type DbFunctions,
type FieldSpec,
type SchemaResponse,
type ConnQuery,
type ConnQueryResults,
} from "bknd";
import {
ParseJSONResultsPlugin,
type ColumnDataType,
type ColumnDefinitionBuilder,
type Kysely,
type KyselyPlugin,
type SelectQueryBuilder,
} from "kysely";
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/postgres";
export type QB = SelectQueryBuilder<any, any, any>;
export const plugins = [new ParseJSONResultsPlugin()];
export abstract class PostgresConnection extends Connection {
protected override readonly supported = {
batching: true,
softscans: true,
};
constructor(kysely: Kysely<any>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
super(
kysely,
fn ?? {
jsonArrayFrom,
jsonBuildObject,
jsonObjectFrom,
},
_plugins ?? plugins,
);
}
override getFieldSchema(spec: FieldSpec): SchemaResponse {
this.validateFieldSpecType(spec.type);
let type: ColumnDataType = spec.type;
if (spec.primary) {
if (spec.type === "integer") {
type = "serial";
}
}
switch (spec.type) {
case "blob":
type = "bytea";
break;
case "date":
case "datetime":
// https://www.postgresql.org/docs/17/datatype-datetime.html
type = "timestamp";
break;
case "text":
// https://www.postgresql.org/docs/17/datatype-character.html
type = "varchar";
break;
}
return [
spec.name,
type,
(col: ColumnDefinitionBuilder) => {
if (spec.primary) {
return col.primaryKey();
}
if (spec.references) {
return col
.references(spec.references)
.onDelete(spec.onDelete ?? "set null")
.onUpdate(spec.onUpdate ?? "no action");
}
return spec.nullable ? col : col.notNull();
},
];
}
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
return this.kysely.transaction().execute(async (trx) => {
return Promise.all(qbs.map((q) => trx.executeQuery(q)));
}) as any;
}
}

View File

@@ -1,127 +0,0 @@
import { type SchemaMetadata, sql } from "kysely";
import { BaseIntrospector } from "bknd";
type PostgresSchemaSpec = {
name: string;
type: "VIEW" | "BASE TABLE";
columns: {
name: string;
type: string;
notnull: number;
dflt: string;
pk: boolean;
}[];
indices: {
name: string;
origin: string;
partial: number;
sql: string;
columns: { name: string; seqno: number }[];
}[];
};
export class PostgresIntrospector extends BaseIntrospector {
async getSchemas(): Promise<SchemaMetadata[]> {
const rawSchemas = await this.db
.selectFrom("pg_catalog.pg_namespace")
.select("nspname")
.$castTo<{ nspname: string }>()
.execute();
return rawSchemas.map((it) => ({ name: it.nspname }));
}
async getSchemaSpec() {
const query = sql`
WITH tables_and_views AS (
SELECT table_name AS name,
table_type AS type
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type IN ('BASE TABLE', 'VIEW')
AND table_name NOT LIKE 'pg_%'
AND table_name NOT IN (${this.getExcludedTableNames().join(", ")})
),
columns_info AS (
SELECT table_name AS name,
json_agg(json_build_object(
'name', column_name,
'type', data_type,
'notnull', (CASE WHEN is_nullable = 'NO' THEN true ELSE false END),
'dflt', column_default,
'pk', (SELECT COUNT(*) > 0
FROM information_schema.table_constraints tc
INNER JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = c.table_name
AND tc.constraint_type = 'PRIMARY KEY'
AND kcu.column_name = c.column_name)
)) AS columns
FROM information_schema.columns c
WHERE table_schema = 'public'
GROUP BY table_name
),
indices_info AS (
SELECT
t.relname AS table_name,
json_agg(json_build_object(
'name', i.relname,
'origin', pg_get_indexdef(i.oid),
'partial', (CASE WHEN ix.indisvalid THEN false ELSE true END),
'sql', pg_get_indexdef(i.oid),
'columns', (
SELECT json_agg(json_build_object(
'name', a.attname,
'seqno', x.ordinal_position
))
FROM unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinal_position)
JOIN pg_attribute a ON a.attnum = x.attnum AND a.attrelid = t.oid
))) AS indices
FROM pg_class t
LEFT JOIN pg_index ix ON t.oid = ix.indrelid
LEFT JOIN pg_class i ON i.oid = ix.indexrelid
WHERE t.relkind IN ('r', 'v') -- r = table, v = view
AND t.relname NOT LIKE 'pg_%'
GROUP BY t.relname
)
SELECT
tv.name,
tv.type,
ci.columns,
ii.indices
FROM tables_and_views tv
LEFT JOIN columns_info ci ON tv.name = ci.name
LEFT JOIN indices_info ii ON tv.name = ii.table_name;
`;
const tables = await this.executeWithPlugins<PostgresSchemaSpec[]>(query);
return tables.map((table) => ({
name: table.name,
isView: table.type === "VIEW",
columns: table.columns.map((col) => {
return {
name: col.name,
dataType: col.type,
isNullable: !col.notnull,
// @todo: check default value on 'nextval' see https://www.postgresql.org/docs/17/datatype-numeric.html#DATATYPE-SERIAL
isAutoIncrementing: true, // just for now
hasDefaultValue: col.dflt != null,
comment: undefined,
};
}),
indices: table.indices.map((index) => ({
name: index.name,
table: table.name,
isUnique: index.sql?.match(/unique/i) != null,
columns: index.columns.map((col) => ({
name: col.name,
order: col.seqno,
})),
})),
}));
}
}

View File

@@ -1,43 +0,0 @@
import { Kysely } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "bknd";
import { PostgresJSDialect } from "kysely-postgres-js";
import $postgresJs, { type Sql, type Options, type PostgresType } from "postgres";
export type PostgresJsConfig = Options<Record<string, PostgresType>>;
export class PostgresJsConnection extends PostgresConnection {
override name = "postgres-js";
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<void> {
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 });
}

View File

@@ -1,46 +0,0 @@
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<T> = new (...args: any[]) => T;
export type CustomPostgresConnection = {
supports?: PostgresConnection["supported"];
fn?: Partial<DbFunctions>;
plugins?: KyselyPlugin[];
excludeTables?: string[];
};
export function createCustomPostgresConnection<
T extends Constructor<Dialect>,
C extends ConstructorParameters<T>[0],
>(
name: string,
dialect: Constructor<Dialect>,
options?: CustomPostgresConnection,
): (config: C) => PostgresConnection {
const supported = {
batching: true,
...((options?.supports ?? {}) as any),
};
return (config: C) =>
new (class extends PostgresConnection {
override name = name;
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);
}

View File

@@ -1,5 +0,0 @@
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";

View File

@@ -1,16 +0,0 @@
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",
}),
});
});

View File

@@ -1,16 +0,0 @@
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",
}),
});
});

View File

@@ -1,218 +0,0 @@
import { describe, beforeAll, afterAll, expect, it, afterEach } from "bun:test";
import type { PostgresConnection } from "../src";
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<typeof PostgresConnection>;
cleanDatabase?: (connection: InstanceType<typeof PostgresConnection>) => Promise<void>;
};
export async function defaultCleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// 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();
}
async function cleanDatabase(
connection: InstanceType<typeof PostgresConnection>,
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());
// @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();
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 = em(
{
posts: entity("posts", {
title: text().required(),
content: text(),
}),
comments: entity("comments", {
content: text(),
}),
},
(fns, s) => {
fns.relation(s.comments).manyToOne(s.posts);
fns.index(s.posts).on(["title"], true);
},
);
const app = createApp({
connection,
config: {
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);
});
it("should support uuid", async () => {
const schema = em(
{
posts: entity(
"posts",
{
title: text().required(),
content: text(),
},
{
primary_format: "uuid",
},
),
comments: entity("comments", {
content: text(),
}),
},
(fns, s) => {
fns.relation(s.comments).manyToOne(s.posts);
fns.index(s.posts).on(["title"], true);
},
);
const app = createApp({
connection,
config: {
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);
});
});
}

View File

@@ -1,33 +0,0 @@
{
"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"]
}