kastor0.1.0
Get started

GUIDE 14From Drizzle

Migrate from Drizzle ORM to kastor

Drizzle and kastor both keep the schema in TypeScript and infer types from it, so the move is mostly vocabulary: pgTable becomes table(), operator functions become where objects, and drizzle-kit hands migrations to kastor migrate.

What changes and what stays

If you are planning to migrate from Drizzle, you already have the hard part done: your schema lives in TypeScript, your types are inferred from it, and nothing is generated. kastor works the same way, so the move does not change how your project builds. What changes is the query surface. Drizzle gives you two APIs, a SQL shaped builder (db.select().from(users).where(eq(...))) and the relational query API (db.query.users.findMany). kastor has one object API that looks like the relational one, and covers writes, nested writes, aggregates and grouping with the same shapes.

Your database and your data stay where they are. Nothing in this guide copies rows or recreates tables. The order is always the same: get a kastor schema that matches the live database, record it as the starting point of kastor migrations, then move queries one module at a time while both libraries run side by side.

Step 1: install kastor next to Drizzle

kastor ships no driver, so you keep the one Drizzle already uses. A Postgres project keeps pg (or postgres), a SQLite project keeps better-sqlite3, a Turso project keeps @libsql/client.

Terminal
npm install kastor-orm
npx kastor-orm init --db postgres

init writes kastor.config.ts, a starter schema at src/db/schema.ts, a seed file and a .env with DATABASE_URL. Point that variable at the same database drizzle.config.ts uses. If your Drizzle schema already lives in src/db/schema.ts, pick another path for kastor, for example src/db/kastor-schema.ts, in the config's schema field until the move is done.

Step 2: get a kastor schema from the database

You could translate the Drizzle schema by hand, and the mapping table below shows how. The faster way is to let kastor read the database that drizzle-kit created: db pull introspects tables, columns, defaults, indexes and foreign keys and writes a TypeScript file with every table, a schema object and a relations function guessed from the foreign keys.

Terminal
npx kastor db pull --out src/db/kastor-schema.ts

Here is a typical Drizzle schema and what the kastor version looks like after you tidy the export names:

Drizzle: src/db/schema.ts (before)
import { pgTable, serial, varchar, text, boolean, integer, timestamp, pgEnum, index } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'

export const role = pgEnum('role', ['user', 'admin'])

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  name: text('name'),
  role: role('role').notNull().default('user'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
})

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 200 }).notNull(),
  published: boolean('published').notNull().default(false),
  authorId: integer('author_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
}, (t) => [index('posts_author_idx').on(t.authorId)])

export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }))
export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
}))
src/db/kastor-schema.ts
import { table, t, index, one, many } from 'kastor-orm'

export const users = table('users', {
  id: t.id(),
  email: t.string(255).unique(),
  name: t.text().nullable(),
  role: t.enum(['user', 'admin']).default('user'),
  createdAt: t.datetime().map('created_at').defaultNow(),
})

export const posts = table('posts', {
  id: t.id(),
  title: t.string(200),
  published: t.boolean().default(false),
  authorId: t.int().map('author_id').references(() => users.id, { onDelete: 'cascade' }),
}, { indexes: (p) => [index(p.authorId).name('posts_author_idx')] })

export const schema = { users, posts }

export const relations = ({ users, posts }: typeof schema) => ({
  users: { posts: many(posts) },
  posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
})

Two habits flip. Drizzle columns are nullable unless you write .notNull(); kastor columns are required unless you write .nullable(). And the Drizzle column name argument becomes .map('db_name'), which you only need when the database name differs from the TypeScript key.

Drizzlekastor
pgTable, mysqlTable, sqliteTabletable(), the same on every database
serial().primaryKey()t.id()
varchar('x', ...), text(), integer()t.string(len), t.text(), t.int()
timestamp().defaultNow()t.datetime().defaultNow()
uuid().defaultRandom()t.uuid().defaultRandom()
pgEnumt.enum([...])
no .notNull().nullable()
index().on(...), uniqueIndex()index(...), uniqueIndex(...) in indexes
relations(users, ...)relations function on the client
typeof users.$inferSelectInferSelect<typeof users>
drizzle-kit generate and migratenpx kastor migrate dev and migrate deploy
drizzle-kit push, pull, studionpx kastor db push, db pull, studio

Step 3: adopt the database with a baseline

Drizzle records applied migrations in its own table (__drizzle_migrations) and journal. kastor does not read that history. It starts from a baseline instead: one migration that describes the database exactly as it is today, recorded as applied without being run.

Terminal
npx kastor migrate baseline --name init
npx kastor migrate status
npx kastor migrate dev --create-only --name check

migrate status should list the baseline as applied and nothing pending. The last command is a safety check against a copy of the database: if the pulled schema matches, kastor reports that the database is already in sync and writes no file. If it writes a file, read it, because it shows where the schema file and the database disagree. Run the baseline once per environment (development, staging, production) before the first migrate deploy there.

Note

Pick one owner for schema changes during the move. Until you remove drizzle-kit, keep generating migrations with it and run db pull again afterwards; once kastor owns migrations, stop running drizzle-kit entirely so the two never fight over the same tables.

Step 4: one client, sharing the pool

Both libraries wrap a driver instance, so you can hand them the same pool. That keeps the number of open connections unchanged while both are in use.

src/db/index.ts
import pg from 'pg'
import { drizzle } from 'drizzle-orm/node-postgres'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './kastor-schema'

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 })

// Old code keeps importing this until it moves.
export const drizzleDb = drizzle(pool)

export const db = kastor({ adapter: postgres(pool), schema, relations })

Step 5: rewrite queries side by side

The relational API maps almost word for word. with becomes include, columns becomes select, and operator functions become keys of the where object.

Drizzle: src/posts/queries.ts (before)
import { and, desc, eq, ilike, inArray, sql } from 'drizzle-orm'
import { drizzleDb as db } from '../db'
import { posts, users } from '../db/schema'

export const feed = (authorIds: number[]) =>
  db.query.posts.findMany({
    where: and(eq(posts.published, true), inArray(posts.authorId, authorIds)),
    with: { author: { columns: { name: true } } },
    orderBy: [desc(posts.id)],
    limit: 20,
  })

export const search = (q: string) =>
  db.select().from(users).where(ilike(users.email, `%${q}%`))

export const publish = (id: number) =>
  db.update(posts).set({ published: true }).where(eq(posts.id, id)).returning()

export const countByAuthor = () =>
  db.select({ authorId: posts.authorId, n: sql<number>`count(*)` }).from(posts).groupBy(posts.authorId)
src/posts/queries.ts
import { db } from '../db'

export const feed = (authorIds: number[]) =>
  db.posts.findMany({
    where: { published: true, authorId: { in: authorIds } },
    include: { author: { select: { name: true } } },
    orderBy: { id: 'desc' },
    take: 20,
  })

export const search = (q: string) =>
  db.users.findMany({ where: { email: { contains: q, mode: 'insensitive' } } })

export const publish = (id: number) =>
  db.posts.update({ where: { id }, data: { published: true } })

export const countByAuthor = () =>
  db.posts.groupBy({ by: 'authorId', _count: { _all: true } })
Drizzlekastor
eq(col, v), ne{ col: v }, { col: { not: v } }
gt, gte, lt, ltegt, gte, lt, lte keys
inArray, notInArrayin, notIn
like, ilikecontains, startsWith, endsWith, with mode: 'insensitive'
isNull, isNotNull{ col: null }, { col: { not: null } }
and, or, notAND, OR, NOT
exists(subquery)relation filters some, every, none, is
limit, offsettake, skip, or findPage for cursors
insert().values().returning()create, createManyAndReturn
onConflictDoUpdateupsert
db.transaction(async (tx) => ...)db.$transaction(async (tx) => ...)
db.execute(sql`...`)db.$sql`...` or db.$execute`...`

Writes with relations are where kastor saves the most code. Drizzle inserts the parent, reads the id and inserts the children inside a transaction you write yourself. kastor does that in one call:

src/users/register.ts
import { db } from '../db'

export const register = (email: string) =>
  db.users.create({
    data: { email, posts: { create: [{ title: 'Hello' }, { title: 'Second post' }] } },
    include: { posts: true },
  })

Raw SQL keeps working. The sql tag from kastor-orm builds fragments for where, and db.$sql<Row> runs a full statement with every interpolated value sent as a parameter.

Step 6: switch CI and remove Drizzle

Once a module has no Drizzle import left, delete its old file. When the last one goes, remove drizzle-orm, drizzle-kit and drizzle.config.ts, and replace the migrate step in your pipeline:

.github/workflows/deploy.yml
- run: npm ci
- run: npx tsc --noEmit
- run: npx kastor migrate deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

Nothing else changes in the build, because neither tool generates code.

Pitfalls to check

  • Nullability flips. A Drizzle column without .notNull() is nullable. If you translate by hand, add .nullable() to every such column, or the kastor types will promise values that can be null.
  • Names come from the database. db pull names exports after tables in camelCase. If you used the Drizzle casing option, columns come back with .map() calls; that is correct, keep them.
  • Timestamps and numerics. Drizzle can return timestamps as strings with mode: 'string'. kastor always returns Date for t.datetime(), a YYYY-MM-DD string for t.date() and a string for t.decimal(), so check code that formats them.
  • $onUpdate. For timestamps, use t.datetime().updatedAt(), which kastor fills on every create and update. Other computed values belong in the function that writes, or in a database trigger.
  • Prepared statements. Drizzle's .prepare() has no direct counterpart; the driver still caches statements where it supports that.
  • Two migration owners. Never run drizzle-kit push after the baseline. It would change the database behind kastor's history and the next migrate dev would try to undo it.

The playground shows the SQL each kastor query compiles to on your database.

Questions people ask

Q1Can I keep my drizzle-kit migration history?

Keep the drizzle folder in git as a record, but let kastor start from a baseline. npx kastor migrate baseline --name init writes one migration that matches the live database and marks it applied, so migrate deploy never replays the Drizzle files. New changes after that come from npx kastor migrate dev.

Q2Does kastor have an equivalent of Drizzle relational queries?

Yes. db.query.users.findMany({ with: { posts: true } }) becomes db.users.findMany({ include: { posts: true } }). Nested includes take their own where, orderBy and take, and load in one statement per call through JSON aggregation.

Q3Can Drizzle and kastor run on the same database at the same time?

Yes. Both are thin layers over your driver. You can even pass the same pg.Pool to both, which keeps the connection count where it was while you move modules one by one.

Q4Is there a replacement for the eq, and, or helpers?

kastor uses plain objects: eq(users.email, x) is { email: x }, and(a, b) is two keys in one object or AND: [a, b], or() is OR and inArray is { in: [...] }. For anything else, the sql tag works inside where.

Q5Do I lose the SQL like builder?

kastor has no chained select builder. Its object API covers filters, relations, aggregates and grouping, and db.$sql runs any SQL with bound parameters and a typed row. If a module is a large reporting query, it is fine to leave it on Drizzle or move it to $sql.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.