Skip to content

Switch to Bun.sql + drizzle-orm v1 beta#265

Open
evantahler wants to merge 9 commits into
mainfrom
evantahler/bun-sql-drizzle-v1
Open

Switch to Bun.sql + drizzle-orm v1 beta#265
evantahler wants to merge 9 commits into
mainfrom
evantahler/bun-sql-drizzle-v1

Conversation

@evantahler

Copy link
Copy Markdown
Member

Summary

  • Replace pg (node-postgres) with Bun's native SQL client (drizzle-orm/bun-sql) for PostgreSQL connections — drops pg and @types/pg dependencies
  • Upgrade from drizzle-orm@0.45.x to drizzle-orm@1.0.0-beta.18
  • Replace drizzle-zod with built-in drizzle-orm/zod (consolidated in v1)
  • Migrate to drizzle v1 migration folder format (timestamp-prefixed directories, no more meta/_journal.json)
  • Update scaffold/generator to produce v1-compatible migration structures

Breaking changes

  • api.db.pool (pg.Pool) → api.db.client (Bun.SQL)
  • drizzle-zod peer dep removed → use drizzle-orm/zod
  • Existing projects must run drizzle-kit up --dialect postgresql --out drizzle to upgrade migration folders

Notable implementation detail

Bun.sql has a bug where rapidly creating and closing many SQL instances (e.g., in test suites that start/stop the server per file) causes "Failed query" errors. Worked around this by caching the SQL client instance and reusing it across start/stop cycles when the connection string hasn't changed.

Closes #263

Test plan

  • All 312 package tests pass
  • All 193 example backend tests pass
  • Lint passes
  • Full CI passes (bun run ci)
  • Docs updated (initializers guide, utilities reference)

🤖 Generated with Claude Code

evantahler and others added 4 commits March 17, 2026 22:36
Replace `pg` (node-postgres) with Bun's native SQL client for PostgreSQL
connections. This drops the `pg` and `@types/pg` dependencies in favor of
Bun's built-in `SQL` class, which uses native C/Zig bindings with the binary
wire protocol for better performance.

Also upgrades from drizzle-orm 0.45.x to the v1 beta (1.0.0-beta.18):
- Migrations use the new v1 folder format (timestamp-prefixed directories)
- `drizzle-zod` replaced by built-in `drizzle-orm/zod`
- Old `meta/_journal.json` migration journal removed

Breaking changes:
- `api.db.pool` (pg.Pool) replaced with `api.db.client` (Bun.SQL)
- `drizzle-zod` peer dependency removed (use `drizzle-orm/zod`)
- Existing migration folders must be upgraded via `drizzle-kit up`

Closes #263

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- config.md: Remove pool.min and pool.allowExitOnIdle (not in Bun.sql),
  update pool tuning table and description
- initializers.md: Update example type from Pool to SQL client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of hardcoding zod, drizzle-orm, drizzle-kit, and prettier
version strings, read them from the framework's own package.json
peerDependencies and devDependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of shipping a hardcoded migration SQL string, the scaffold now
runs `bun install` + `drizzle-kit generate` against the scaffolded
schema to produce the migration dynamically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@theintjengineer

Copy link
Copy Markdown

I have been playing around with the file keryx/initializers/db.ts from the package itself; please take a look at the comments to see some modifications|comments|stuff I've observed [look for the ✅ marks].

import { $ } from "bun";
import { DefaultLogger, type LogWriter, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sql";
import { migrate } from "drizzle-orm/bun-sql/migrator";
import fs from "node:fs";
import { unlink } from "node:fs/promises";
import path from "path";
import { SQL } from "bun";
import { api, logger } from "../api";
import { Initializer } from "../classes/Initializer";
import { ErrorType, TypedError } from "../classes/TypedError";
import { config } from "../config";
import { formatConnectionStringForLogging } from "../util/connectionString";

const namespace = "db";

declare module "../classes/API" {
  export interface API {
    [namespace]: Awaited<ReturnType<DB["initialize"]>>;
  }
}

export class DB extends Initializer {
  constructor() {
    super(namespace);
    this.loadPriority = 100;
    this.startPriority = 100;
    this.stopPriority = 910;
  }

  async initialize() {
    const dbContainer = {} as {
      db: ReturnType<typeof drizzle>;
      client: InstanceType<typeof SQL>;
    };
    return Object.assign(
      {
        generateMigrations: this.generateMigrations,
        clearDatabase: this.clearDatabase,
      },
      dbContainer,
    );
  }

  async start() {
    api.db.client = new SQL(config.database.connectionString);

    class DrizzleLogger implements LogWriter {
      write(message: string) {
        logger.debug(message);
      }
    }

    api.db.db = drizzle({
      client: api.db.client,
      logger: new DefaultLogger({ writer: new DrizzleLogger() }),
    });

    try {
      await api.db.db.execute(sql`SELECT NOW()`);
    } catch (e) {
      throw new TypedError({
        type: ErrorType.SERVER_INITIALIZATION,
        message: `Cannot connect to database (${formatConnectionStringForLogging(config.database.connectionString)}): ${e}`,
      });
    }

    if (config.database.autoMigrate) {
      try {
        const migrationsFolder = path.join(api.rootDir, "drizzle");
        const journalPath = path.join(
          migrationsFolder,
          "meta",
          "_journal.json",
        );
        if (!fs.existsSync(journalPath)) {
          fs.mkdirSync(path.dirname(journalPath), { recursive: true });
          fs.writeFileSync(journalPath, JSON.stringify({ entries: [] }));
          logger.info("created empty drizzle migrations journal");
        }
        // ✅ MOD 1: Pass object with migrationsFolder property
        // `migrate()` from drizzle-orm/bun-sql/migrator expects an object with migrationsFolder property,
        // not just a string path --> { migrationsFolder }
        await migrate(api.db.db, { migrationsFolder });
        logger.info("database migrated successfully");
      } catch (e) {
        throw new TypedError({
          type: ErrorType.SERVER_INITIALIZATION,
          message: `Cannot migrate database (${formatConnectionStringForLogging(config.database.connectionString)}): ${e}`,
        });
      }
    }

    logger.info(
      `database connection established (${formatConnectionStringForLogging(config.database.connectionString)})`,
    );
  }

  async stop() {
    if (api.db.db && api.db.client) {
      try {
        await api.db.client.close();
        logger.info("database connection closed");
      } catch (e) {
        logger.error("error closing database connection", e);
      }
    }
  }

  /**
   * Generate migrations for the database schema.
   */
  async generateMigrations() {
    // ✅ MOD 2: Use `defineConfig` from `drizzle-kit` instead of the `Config` type
    // - the `url` property isn't recognized in the `DrizzleMigrateConfig` type
    // - for Bun SQL, we need a different approach
    // - also, see comment at the end of the file
    const migrationConfig = {
      dialect: "postgresql" as const,
      schema: path.join(api.rootDir, "schema", "*"),
      driver: "bun" as const, // ✅ Specify Bun driver
      dbCredentials: {
        url: config.database.connectionString,
      },
      out: path.join(api.rootDir, "drizzle"),
    };

    const fileContent = `export default ${JSON.stringify(migrationConfig, null, 2)}`;
    const tmpfilePath = path.join(api.rootDir, "drizzle", "config.tmp.ts");

    try {
      await Bun.write(tmpfilePath, fileContent);
      const { exitCode, stdout, stderr } =
        await $`bun drizzle-kit generate --config ${tmpfilePath}`;
      logger.trace(stdout.toString());
      if (exitCode !== 0) {
        {
          throw new TypedError({
            message: `Failed to generate migrations: ${stderr.toString()}`,
            type: ErrorType.SERVER_INITIALIZATION,
          });
        }
      }
    } finally {
      const filePointer = Bun.file(tmpfilePath);
      if (await filePointer.exists()) await unlink(tmpfilePath);
    }
  }

  /**
   * Erase all the tables in the active database.
   */
  async clearDatabase(restartIdentity = true, cascade = true) {
    if (Bun.env.NODE_ENV === "production") {
      throw new TypedError({
        message: "clearDatabase cannot be called in production",
        type: ErrorType.SERVER_INITIALIZATION,
      });
    }

    const result = await api.db.db.execute(
      sql`SELECT tablename FROM pg_tables WHERE schemaname = CURRENT_SCHEMA`,
    );

    for (const row of result) {
      logger.debug(`truncating table ${row.tablename}`);
      await api.db.db.execute(
        sql.raw(
          `TRUNCATE TABLE "${row.tablename}" ${restartIdentity ? "RESTART IDENTITY" : ""} ${cascade ? "CASCADE" : ""} `,
        ),
      );
    }
  }
}

extra comments

// before, we had something like
const migrationConfig: DrizzleMigrateConfig = {
  dialect: "postgresql" as const,
  schema: path.join("schema", "*"),
  dbCredentials: {
    url: config.database.connectionString,
  },
  out: path.join("drizzle"),
};

// after:
const migrationConfig = {
  dialect: "postgresql" as const,
  schema: path.join(api.rootDir, "schema", "*"),
  driver: "bun" as const, // Required for Bun SQL
  dbCredentials: {
    url: config.database.connectionString,
  },
  out: path.join(api.rootDir, "drizzle"),
};

I, basically,

  • removed the DrizzleMigrateConfig type annotation [it doesn’t support Bun driver yet]
    • I don't fully like this, as we lose to type annotation, but we can think of a cleaner variant later on
      • maybe extend DrizzleMigrateConfig, or create an issue on Drizzle's GitHub, or create our own interface for the config so we can also remove those consts, etc., whatever; at least now I've got an idea about the stuff that works|doesn't work
  • added the driver: "bun" property ⟹ tells drizzle-kit to use Bun’s native driver
  • used api.rootDir for absolute paths [important for schema and out directories]

Also, the url in dbCredentials is valid when we specify driver: "bun"; there was an error there when I was trying things out, and was using the Config type; basically, drizzle-kit's Config type somehow doesn’t fully support the Bun driver yet, but the runtime itself does.

I used this file with the project generated by the CLI itself1 and the full integration worked—creating a user, creating a session, getting the user session, destroying the session, ensuring there's no user session—worked.

It's not production ready haha, but it highlights some of the stuff one could take note of|pay attention to when implementing the version with Bun's native SQL driver.

Anyway. Looking forward to seeing this becoming more and more Bun-native.

Cheers!
TIE

Footnotes

  1. The only thing I changed in the project was changing the 3rd argument of pgTable() in schema/users.ts, since the deprecated warning was driving me crazy haha; but that's unrelated to the SQL driver we're talking about.

@evantahler

Copy link
Copy Markdown
Member Author

Thanks for this! Would you be able to make this a PR so we can see the diff and comment line-by-line?

@theintjengineer

Copy link
Copy Markdown

Evan, check this https://github.com/theintjengineer/tie-keryx, if you can.
Cheers.

Resolve version conflict in package.json (take 0.16.1 from main)
and regenerate bun.lockb.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@evantahler

Copy link
Copy Markdown
Member Author

@theintjengineer I think I missing the meta about your PR. What problem are you trying to solve or feature are your adding? I'm missing something...

@evantahler

Copy link
Copy Markdown
Member Author

The changes in #285 will confound this work a bit further

evantahler and others added 4 commits April 4, 2026 17:30
Use bunx instead of bun to run drizzle-kit in scaffold generator so the
binary resolves from the scaffolded project's node_modules. Add 60s
timeouts to scaffold/upgrade tests that now run bun install + drizzle-kit
generate. Bump drizzle-orm and drizzle-kit from beta.18 to beta.20.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Resolve conflicts: keep bun-sql/drizzle-v1 driver and version pins,
adopt main's biome formatter (drop prettier), update transaction
middleware and utility from pg PoolClient to Bun.sql ReservedSQL,
bump version to 0.22.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
drizzle-orm v1 beta does not include a /zod subpath — the zod integration
remains in the separate drizzle-zod package. Restore drizzle-zod as a
dependency in example/backend and change imports from drizzle-orm/zod to
drizzle-zod. Also fix implicit any on transaction callback parameter by
adding an explicit type annotation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The test checked pg Pool's totalCount to verify connection release. With
Bun.sql there is no pool count API — verify behavior by confirming the
success path returns correctly and the error path completes without
hanging (which would indicate a leaked reserved connection).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate drizzle-orm/bun-sql

2 participants