Implementation Investigation

Staged Drizzle Migration for Better Auth and D1

This report evaluates migrating the project to Drizzle in stages: first implementing Better Auth through the Drizzle adapter, then migrating the rest of the existing Cloudflare D1 data layer.

Reviewed package.json, wrangler.jsonc, src/lib/auth.ts, src/lib/order-service-data.ts, src/email-status-durable-object.ts, and migrations/0001-0008 · Current Better Auth and Cloudflare D1 docs retrieved · Investigated 2026-06-30

Summary

Verdict Use Drizzle for Better Auth first, but keep the app data layer on direct D1 until auth is stable. This is a reasonable staged migration if schema ownership and migration tooling are kept explicit.
Primary risk Running two database styles against the same D1 database can create drift: Drizzle schema/migrations for auth and hand-written SQL/direct D1 queries for the existing app.
Biggest challenge Choosing one migration source of truth before expanding Drizzle beyond auth. Mixing wrangler d1 migrations, drizzle-kit, generated Better Auth schema, and the existing runtime ensureDatabase() bootstrap will otherwise become confusing.
Recommended shape Phase 1: add Drizzle only for Better Auth tables. Phase 2: convert schema definitions for existing tables without changing queries. Phase 3: migrate query modules feature by feature. Phase 4: remove direct bootstrap SQL once Drizzle migrations own the full schema.

Current State

CONFIRMED

The project is D1-first and not currently Drizzle-backed

wrangler.jsonc defines a D1 binding named DB with migrations in ./migrations. package.json does not currently include drizzle-orm, drizzle-kit, or a Better Auth Drizzle adapter dependency.

Evidence: wrangler.jsonc contains d1_databases[{ binding: "DB" }]; scripts currently run wrangler d1 migrations apply; dependencies include better-auth but not Drizzle packages.
CONFIRMED

Existing app queries are concentrated but extensive

The main application data layer lives in src/lib/order-service-data.ts. It uses env.DB.prepare(...).bind(...).run/all/first and db.batch(...) throughout. It also carries an in-code SCHEMA_SQL bootstrap that duplicates and extends the migration files.

Evidence: source review found hundreds of direct D1 statements in src/lib/order-service-data.ts, plus additional direct D1 usage in src/email-status-durable-object.ts.
Implication

Migrating the whole app to Drizzle is not just a dependency change. It is a query-by-query rewrite, plus a decision about whether runtime schema bootstrapping should continue to exist.

CONFIRMED

Better Auth is currently a placeholder that must change anyway

src/lib/auth.ts currently imports better-sqlite3 and creates a module-level database with new Database(). That is not suitable for the Cloudflare Worker D1 binding and should be replaced whether the implementation uses direct D1/Kysely or Drizzle.

Evidence: src/lib/auth.ts uses database: new Database() and does not receive request-scoped env.DB.

Migration Challenges

HIGH RISK

Migration ownership can split across too many tools

Better Auth's Drizzle path generates Drizzle schema, then drizzle-kit generates/applies migrations. The project currently applies SQL migrations with Wrangler. If both are used independently, schema history can diverge between local, preview, and production databases.

Recommended control
  • During Phase 1, keep Wrangler D1 migrations as the applied migration mechanism, even if Drizzle/Better Auth generate the schema or SQL drafts.
  • Commit generated Drizzle schema for auth, but convert the resulting migration into the existing migrations/0009_... convention unless the team explicitly decides to switch migration runners.
  • Do not let runtime ensureDatabase() become the source of truth for new Better Auth tables.
IMPORTANT

Auth tables use Better Auth naming conventions

Better Auth core tables are normally user, session, account, and verification, with camelCase columns such as emailVerified, createdAt, and userId. The app's existing schema uses snake_case columns such as created_at, service_date, and order_json.

Why this matters
  • It is acceptable to keep Better Auth's generated names for auth tables and use app-specific snake_case elsewhere.
  • However, if the team wants a single naming convention, the Better Auth model/table/field mapping must be configured up front and re-generated after plugin changes.
  • Renaming auth tables after users exist is more disruptive than choosing names before launch.
MEDIUM RISK

Drizzle in Workers must remain request-env scoped

The Drizzle client for D1 must be created from the current request's env.DB binding, not from a module-level database connection. The same constraint applies to Better Auth: export a createAuth(env) factory rather than a static singleton.

import { drizzle } from "drizzle-orm/d1";

export const createDb = (env: Env) => drizzle(env.DB, { schema });
MEDIUM RISK

Direct D1 and Drizzle transaction semantics differ

Existing code often uses db.batch() for grouped writes. Drizzle provides its own transaction and batch patterns, but D1 support and behavior should be validated in the Worker runtime before rewriting critical write flows such as publishing an order, recording hymn plays, or updating email delivery status.

Evidence: direct D1 batching appears in seed/bootstrap paths, order save/delete paths, hymn-play updates, email settings, and team membership writes.
LOW RISK

Generated auth schema must track plugins

Better Auth plugin changes can add tables, columns, or relations. A Drizzle-first auth implementation should treat npx auth@latest generate as part of the auth-change workflow and should review the generated diff before applying it to D1.

Staged Implementation

Phase 0: Decide migration boundaries

  1. Keep existing app tables and direct D1 queries unchanged.
  2. Add Drizzle only for Better Auth and only under auth/database files such as src/db/auth-schema.ts and src/db/client.ts.
  3. Keep applied migrations in migrations/ via Wrangler until a later explicit migration-runner switch.

Phase 1: Better Auth through Drizzle

  1. Add dependencies: drizzle-orm, drizzle-kit as a dev dependency, and use the Better Auth Drizzle adapter export available in Better Auth 1.6.23 (better-auth/adapters/drizzle) or the dedicated package path if chosen after install testing.
  2. Generate the Better Auth Drizzle schema from the final auth config with npx auth@latest generate.
  3. Add an auth migration under migrations/0009_... and apply it locally/remotely with the existing Wrangler scripts.
  4. Replace src/lib/auth.ts with an env-scoped createAuth(env) factory that wraps drizzle(env.DB) in the Drizzle adapter.
  5. Route /api/auth/* to createAuth(env).handler(request) and validate GET /api/auth/ok.

Phase 2: Model existing tables without rewriting queries

Add Drizzle table definitions for the current app schema, but do not switch production query paths yet. This gives compile-time schema feedback and exposes naming/type mismatches while leaving behavior unchanged.

  • Model service_types, orders_of_service, hymns, teams, team_members, and join tables in Drizzle.
  • Add type aliases such as InferSelectModel and compare them to existing hand-written TypeScript interfaces.
  • Do not generate destructive migrations from these table definitions until the schema exactly matches the existing D1 database.

Phase 3: Migrate app queries feature by feature

Convert one bounded area at a time. Good candidates are low-risk lookup/read paths first, then write paths with tests.

  1. Reference data reads: service types, statuses, activity types.
  2. Simple settings and email recipient reads/writes.
  3. Hymn library reads and hymn file metadata writes.
  4. Team and team-member CRUD.
  5. Order-of-service save/publish/delete flows, which are higher risk because they combine JSON payloads, uniqueness checks, hymn play updates, and PDF/email side effects.

Phase 4: Consolidate migration tooling

Once most app tables are represented in Drizzle, decide whether to switch fully to drizzle-kit generated migrations or continue generating SQL and applying through Wrangler. Avoid indefinitely using both as independent sources of truth.

Auth-First Design

A staged Better Auth implementation should isolate auth Drizzle code so it does not force the rest of the app to migrate immediately.

// src/db/client.ts
import { drizzle } from "drizzle-orm/d1";
import * as authSchema from "./auth-schema";

export const createAuthDb = (env: Env) =>
    drizzle(env.DB, { schema: authSchema });

// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthDb } from "../db/client";
import * as authSchema from "../db/auth-schema";

export const createAuth = (env: Env) =>
    betterAuth({
        database: drizzleAdapter(createAuthDb(env), {
            provider: "sqlite",
            schema: authSchema,
        }),
        secret: env.BETTER_AUTH_SECRET,
        baseURL: env.BETTER_AUTH_URL,
        trustedOrigins: [env.BETTER_AUTH_URL],
        emailAndPassword: { enabled: true },
        plugins: [],
    });

The exact import path should be verified during implementation. Local package inspection confirms Better Auth 1.6.23 exports ./adapters/drizzle; Better Auth docs also describe a Drizzle adapter workflow and generate command.

App Migration Design

Schema modeling strategy

Model existing tables exactly first. Do not rename columns or normalize JSON fields as part of the first Drizzle pass. Examples:

import { sqliteTable, text, integer, uniqueIndex } from "drizzle-orm/sqlite-core";

export const ordersOfService = sqliteTable(
    "orders_of_service",
    {
        id: text("id").primaryKey(),
        title: text("title").notNull(),
        serviceTypeId: text("service_type_id").notNull(),
        serviceDate: text("service_date").notNull(),
        status: text("status").notNull().default("Planning"),
        templateId: text("template_id"),
        orderJson: text("order_json").notNull(),
        pdfObjectKey: text("pdf_object_key"),
        publishedAt: text("published_at"),
        createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"),
        updatedAt: text("updated_at").notNull().default("CURRENT_TIMESTAMP"),
    },
    (table) => [
        uniqueIndex("orders_of_service_service_date_unique_idx").on(table.serviceDate),
    ]
);

The snippet is illustrative; defaults and foreign keys should be validated against Drizzle's current SQLite/D1 APIs before use.

Coexistence pattern during migration

Use a shared request-scoped database factory, but let each module choose whether it still needs direct D1 or Drizzle.

export const createDb = (env: Env) => drizzle(env.DB, { schema });
export const getD1 = (env: Env) => env.DB;

This keeps the boundary explicit and makes feature-by-feature migration possible without an all-at-once rewrite.

Recommendation

  1. Proceed with a Drizzle-first Better Auth implementation if the team is comfortable adopting Drizzle as the eventual app-wide data layer.
  2. In Phase 1, restrict Drizzle usage to auth. Do not touch src/lib/order-service-data.ts except where auth guards require session checks.
  3. Use Better Auth's generator to create the Drizzle auth schema, but apply migrations through the existing Wrangler D1 migration path until the team deliberately switches migration tooling.
  4. Before Phase 3, remove or greatly reduce the runtime ensureDatabase() schema creation path. Runtime schema mutation and generated migrations should not both own the same tables long term.
  5. Add tests around each data module before converting that module to Drizzle; this app has many business rules embedded in SQL and JSON update flows.

Validation

  1. Install Drizzle dependencies and run npm run build to confirm Worker-compatible imports.
  2. Run npx auth@latest generate --config src/lib/auth.ts after finalizing Better Auth plugins/options.
  3. Review generated auth schema/migration for D1-compatible SQLite SQL and expected table names.
  4. Apply the auth migration locally with npm run db:migrate:local.
  5. Start the Worker and verify GET /api/auth/ok returns { status: "ok" }.
  6. Exercise sign-up, sign-in, get-session, and sign-out flows through the Better Auth client.
  7. Run npm run test, npm run build, and npm run check before any remote migration.
  8. Before each app module migration, add regression tests for the direct D1 behavior, then rerun the tests after replacing the module with Drizzle queries.

Open Questions