kastor0.1.0
Get started

GUIDE 13From Prisma

Migrate from Prisma to kastor

kastor keeps the query API Prisma made popular and drops the generate step. npx kastor import prisma turns schema.prisma into a TypeScript schema for the same database, npx kastor migrate baseline --from-prisma keeps your migration history, and most client calls only change their first word.

What moves, and what stays put

Most of a Prisma project survives the move unchanged. The database stays, every row stays, the migration history stays, and the query style stays: kastor uses findMany, where, include, select, nested writes and $transaction with the same shapes. What changes is everything around them. The schema becomes a TypeScript module instead of a .prisma file, types are inferred by the compiler instead of generated, and queries run through the driver you install instead of Prisma's engine.

This guide follows the order that keeps production safe: convert the schema, adopt the database without changing it, create the client, then move calls one module at a time while Prisma keeps serving the rest.

Step 1: install kastor and the driver

kastor ships no driver. Install the one that matches the provider in your datasource block: pg for postgresql (also Neon, Supabase and Vercel Postgres) and cockroachdb, mysql2 for mysql (also PlanetScale), better-sqlite3 for sqlite, mssql for sqlserver and mongodb for mongodb.

Terminal
npm install kastor-orm pg

Step 2: convert schema.prisma with kastor import prisma

Run the import from the project root. It finds the schema through the --schema flag, the prisma.schema entry of package.json, prisma/schema.prisma or a prisma/schema folder, in that order. --out picks the output file (default src/db/schema.ts) and --config also writes kastor.config.ts for your datasource, reading the same environment variable as url = env("...").

Terminal
npx kastor import prisma --schema prisma/schema.prisma --out src/db/schema.ts --config

Here is a typical schema with an enum, a one to one, a one to many and an implicit many to many relation:

prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  USER
  ADMIN
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  posts     Post[]
  profile   Profile?
}

model Profile {
  id     Int    @id @default(autoincrement())
  bio    String
  userId Int    @unique
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Post {
  id        String   @id @default(cuid())
  title     String   @db.VarChar(200)
  content   String?
  published Boolean  @default(false)
  views     Int      @default(0)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  tags      Tag[]
  createdAt DateTime @default(now())

  @@index([authorId])
}

model Tag {
  id    Int    @id @default(autoincrement())
  name  String @unique
  posts Post[]
}

The command prints a report. Lines under Not mapped exactly need a decision from you, and lines under Good to know explain a difference in behavior. For this schema everything maps exactly:

Output
kastor / import prisma

  Prisma schema  prisma/schema.prisma
  Provider       postgresql (kastor-orm/postgres)

ok    Wrote src/db/schema.ts: 4 models, 1 join table, 1 enum, 6 relation fields.
ok    Wrote kastor.config.ts for postgres, reading DATABASE_URL.
ok    Every model, field and relation mapped exactly.

Good to know (2)
  info Post.id: Ids from uuid(), cuid(), nanoid() and ulid() are made by Prisma Client, not the database; kastor makes them in JS with the same format.
    The helpers are at the top of the schema file; swap in your own library if you prefer.
  info _PostToTag: Implicit many-to-many tables get a primary key on (A, B), as Prisma 6 creates them on postgresql.
    Databases created with Prisma 5 or older have a unique index _AB_unique instead; replace primaryKey with uniqueIndex(c.A, c.B).name(...) in those tables if migrate dev proposes a change.

And this is the file it writes. Table and constraint names match what Prisma Migrate created (User, _PostToTag, TIMESTAMP(3) columns), so nothing in the database has to change:

src/db/schema.ts
// Schema imported from prisma/schema.prisma by kastor import prisma.
// Prisma provider: postgresql. Table, column and constraint names match the database Prisma created.
// This file is now the source of truth: edit it, then run kastor migrate dev.
import { randomBytes } from 'node:crypto';
import { table, t, index, one, many } from 'kastor-orm';

/** Collision resistant id in the cuid format (Prisma @default(cuid())): 'c', a time part and random base 36 characters. */
function cuid(length = 25): string {
  const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
  const time = Date.now().toString(36).padStart(8, '0');
  const random = Array.from(randomBytes(length), (b) => alphabet[b % 36]).join('');
  return `c${time}${random}`.slice(0, length);
}

export const Role = ['USER', 'ADMIN'] as const;
export type Role = (typeof Role)[number];

export const user = table('User', {
  id: t.id(),
  email: t.text().unique(),
  name: t.text().nullable(),
  role: t.enum(Role, 'Role').default('USER'),
  createdAt: t.datetime().nativeType('timestamp(3)').defaultNow(),
  updatedAt: t.datetime().nativeType('timestamp(3)').updatedAt(),
});

export const profile = table('Profile', {
  id: t.id(),
  bio: t.text(),
  userId: t.int().unique().references(() => user.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
});

export const post = table('Post', {
  id: t.text().primaryKey().default(() => cuid()),
  title: t.string(200),
  content: t.text().nullable(),
  published: t.boolean().default(false),
  views: t.int().default(0),
  authorId: t.int().references(() => user.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
  createdAt: t.datetime().nativeType('timestamp(3)').defaultNow(),
}, {
  indexes: (c) => [index(c.authorId)],
});

export const tag = table('Tag', {
  id: t.id(),
  name: t.text().unique(),
});

/** Join table of the implicit many-to-many relation between Post and Tag, named the way Prisma names it. */
export const postToTag = table('_PostToTag', {
  A: t.text().references(() => post.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
  B: t.int().references(() => tag.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
}, {
  primaryKey: (c) => [c.A, c.B],
  indexes: (c) => [index(c.B).name('_PostToTag_B_index')],
});

export const schema = { user, profile, post, tag, postToTag };

export const relations = ({ user, profile, post, tag, postToTag }: typeof schema) => ({
  user: {
    posts: many(post, { fields: ['id'], references: ['authorId'] }),
    profile: one(profile),
  },
  profile: {
    user: one(user, { fields: ['userId'], references: ['id'] }),
  },
  post: {
    author: one(user, { fields: ['authorId'], references: ['id'] }),
    tags: many(tag, { through: { table: postToTag, fields: ['A'], references: ['B'] } }),
  },
  tag: {
    posts: many(post, { through: { table: postToTag, fields: ['B'], references: ['A'] } }),
  },
});
schema.prismakastor schema
model Userexport const user = table('User', ...)
@@map("users"), @map("created_at")the table name, .map('created_at')
Int @id @default(autoincrement())t.id()
@default(uuid()), cuid()t.uuid().defaultRandom(), a cuid() helper as a JS default
String?, @unique.nullable(), .unique()
@default(now()), @updatedAt.defaultNow(), .updatedAt()
@db.VarChar(200), other native typest.string(200), .nativeType('...')
enum Rolet.enum(Role, 'Role') with a const array
Decimal, BigInt, Json, Bytest.decimal(p, s) (string), t.bigint(), t.json<T>(), t.bytes()
@relation(fields, references, onDelete).references(..., { onDelete }) plus one()
posts Post[]many(post)
implicit tags Tag[] on both sidesthe _PostToTag table plus many(tag, { through })
@@index, @@unique, @@idindex(), uniqueIndex(), primaryKey

Step 3: keep your data and your migration history

Prisma Migrate keeps SQL files in prisma/migrations and records them in the _prisma_migrations table. kastor keeps its own files in kastor/migrations and its own table, _kastor_migrations. The baseline connects the two worlds without touching either the data or Prisma's records:

Terminal
npx kastor migrate baseline --from-prisma prisma/migrations
npx kastor migrate dev
npx kastor migrate status
Output
kastor / migrate baseline

ok    Found 5 tables
ok    Recorded 2 Prisma migrations as history. The files in prisma/migrations were not touched.
ok    Wrote kastor/migrations/20261014101500_baseline/migration.sql (5 steps).
ok    Recorded it as applied without running it. Nothing in the database changed.

ok    The schema file matches the database: kastor migrate dev has nothing to do.

Three things happened. Each Prisma migration that _prisma_migrations marks as finished was recorded as history under prisma/<folder>; migrations that never ran or failed are reported instead of recorded. One baseline migration was written with the SQL that creates the current schema, and marked as applied without running. Then kastor compared the imported schema with the live database. migrate dev confirming Already in sync is your proof that the import is exact.

Commit kastor/migrations. In every other environment (a teammate's database, staging, production) run the same migrate baseline --from-prisma command once: it finds the committed baseline and only marks it as applied there. On a brand new empty database, npx kastor migrate deploy runs the baseline and creates everything. Pass --name init if you prefer that name to baseline.

Note

From this point kastor owns the schema. Stop running prisma migrate dev and prisma db push: they would change tables behind kastor's history. New changes are edits to src/db/schema.ts followed by npx kastor migrate dev --name what_changed.

Step 4: replace new PrismaClient() with one kastor client

Prisma: src/lib/prisma.ts (before)
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
src/db/index.ts
import pg from 'pg'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './schema'

const create = () =>
  kastor({
    adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 })),
    schema,
    relations,
  })

const g = globalThis as unknown as { db?: ReturnType<typeof create> }
export const db = g.db ?? create()
if (process.env.NODE_ENV !== 'production') g.db = db

The globalThis trick is the same one you used for Prisma: it keeps one pool across hot reloads in development. While both clients run, each holds its own connections, so lower Prisma's connection_limit and kastor's max to keep the total under your database limit.

Step 5: move Prisma Client calls

Because the import keeps model names, the first word of each call changes and the rest usually does not. Here is a service before and after:

Prisma: src/posts/service.ts (before)
import { Prisma } from '@prisma/client'
import { prisma } from '../lib/prisma'

export const feed = (tag: string, cursor?: string) =>
  prisma.post.findMany({
    where: { published: true, tags: { some: { name: tag } }, author: { role: 'ADMIN' } },
    include: { author: { select: { name: true } }, tags: true },
    orderBy: { createdAt: 'desc' },
    take: 20,
    ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
  })

export const signUp = (email: string, bio: string) =>
  prisma.user.create({
    data: { email, profile: { create: { bio } }, posts: { create: [{ title: 'Hello' }] } },
    include: { profile: true, posts: true },
  })

export const publish = (id: string, tags: string[]) =>
  prisma.post.update({
    where: { id },
    data: {
      published: true,
      views: { increment: 1 },
      tags: { connectOrCreate: tags.map((name) => ({ where: { name }, create: { name } })) },
    },
  })

export const moveAll = (from: number, to: number) =>
  prisma.$transaction([
    prisma.post.updateMany({ where: { authorId: from }, data: { authorId: to } }),
    prisma.user.delete({ where: { id: from } }),
  ])

export const topAuthors = () =>
  prisma.$queryRaw<{ authorId: number; n: bigint }[]>`
    select "authorId", count(*) as n from "Post" group by 1 order by 2 desc limit ${5}`

export async function register(email: string) {
  try {
    return await prisma.user.create({ data: { email } })
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') return null
    throw e
  }
}
src/posts/service.ts
import { isKastorError } from 'kastor-orm'
import { db } from '../db'

export const feed = (tag: string, after?: string) =>
  db.post.findPage({
    where: { published: true, tags: { some: { name: tag } }, author: { role: 'ADMIN' } },
    include: { author: { select: { name: true } }, tags: true },
    orderBy: { createdAt: 'desc' },
    first: 20,
    after,
  })

export const signUp = (email: string, bio: string) =>
  db.user.create({
    data: { email, profile: { create: { bio } }, posts: { create: [{ title: 'Hello' }] } },
    include: { profile: true, posts: true },
  })

export const publish = (id: string, tags: string[]) =>
  db.post.update({
    where: { id },
    data: {
      published: true,
      views: { increment: 1 },
      tags: { connectOrCreate: tags.map((name) => ({ where: { name }, create: { name } })) },
    },
  })

export const moveAll = (from: number, to: number) =>
  db.$transaction([
    db.post.updateMany({ where: { authorId: from }, data: { authorId: to } }),
    db.user.delete({ where: { id: from } }),
  ])

export const topAuthors = () =>
  db.$sql<{ authorId: number; n: number }>`
    select "authorId", count(*)::int as n from "Post" group by 1 order by 2 desc limit ${5}`

export async function register(email: string) {
  try {
    return await db.user.create({ data: { email } })
  } catch (e) {
    if (isKastorError(e) && e.code === 'UNIQUE_VIOLATION') return null
    throw e
  }
}

The feed also shows an upgrade you can take or leave: Prisma style cursor plus skip: 1 still works in findMany, and findPage returns { items, pageInfo } with opaque cursors when you want pagination done for you. The full mapping:

Prisma Clientkastor
prisma.user.findMany, findFirst, findUnique, ...OrThrowdb.user.findMany, findFirst, findUnique, ...OrThrow
where with equals, in, lt, contains, mode: 'insensitive'the same keys
AND, OR, NOT, some, every, none, is, isNotthe same keys
include, select, _countthe same, loaded in one statement per call
orderBy, take, skip, cursor, distinctthe same, plus findPage
create with nested create, connect, connectOrCreatethe same
update with increment, set, nested disconnect, deletethe same
upsert, createMany with skipDuplicates, createManyAndReturnthe same
updateMany, delete, deleteMany, count, aggregate, groupBy with havingthe same
$transaction([...]), $transaction(async (tx) => ...)the same, with isolationLevel
$queryRaw`...`, $executeRaw`...`$sql`...`, $execute`...`
Prisma.sql, Prisma.join, Prisma.rawsql`...`, sql.join, sql.raw
$queryRawUnsafe(text, ...values)$sql with sql.raw for trusted identifiers only
error P2002, P2003, P2025UNIQUE_VIOLATION, FOREIGN_KEY_VIOLATION, NOT_FOUND
Prisma.UserGetPayload<...>, generated UserInferSelect<typeof user>, or the return type of your query
$connect, $disconnectnot needed, $disconnect()

Middleware and client extensions

Prisma's $use middleware and $extends extensions let you intercept queries and add fields or methods. kastor has no interception API on purpose: every query you read in the code is the query that runs. Here is where the common uses go:

What the middleware or extension didThe closest kastor feature
Log queries and time themthe log option: true, or a function receiving sql, params, durationMs and error
Soft delete (rewrite delete to update, filter reads)softDelete: 'deletedAt' on the table, with restore and hardDelete
Computed fields (result extension)a function over InferSelect<typeof user>
Custom model methods (model extension)functions in a module that call db
Set updatedAt on every write.updatedAt() on the column
Normalize input before writesthe function that writes, or a JS default on the column
Multi tenant filters, row level securitya typed where object you spread, or Postgres RLS set inside $transaction
Retry on serialization failurescatch SERIALIZATION_FAILURE and run the transaction again
src/db/logged.ts
import pg from 'pg'
import { kastor, type InferSelect } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations, user } from './schema'

// Was: prisma.$extends({ query: { $allOperations } }) timing every query
export const db = kastor({
  adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
  schema,
  relations,
  log: (e) => {
    if (e.durationMs > 200) console.warn(`slow query (${e.durationMs} ms): ${e.sql}`)
  },
})

// Was: a result extension adding user.displayName
export const displayName = (u: InferSelect<typeof user>) => u.name ?? u.email.split('@')[0]

// Was: a model extension adding prisma.user.findByEmail
export const findByEmail = (email: string) => db.user.findUnique({ where: { email } })

Step 6: switch CI and remove Prisma

When a module no longer imports @prisma/client, delete the old code. When the last import goes, uninstall prisma and @prisma/client, remove prisma generate from postinstall and the build, and replace prisma migrate deploy in the pipeline:

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

Keep the prisma/migrations folder in git as a record if you like. kastor already holds the history it needs.

Pitfalls to check

  • updatedAt outside kastor. The import turns @updatedAt into .updatedAt(), which kastor fills on every create and update, just like Prisma Client did. Neither tool sets it for writes made with raw SQL or by other programs; use a database trigger if those matter.
  • Client side ids. cuid(), uuid(), nanoid() and ulid() defaults are made in JavaScript by Prisma Client, not by the database. The import writes small helpers at the top of the schema file; rows written by other tools still need ids.
  • Old join tables. Prisma 5 and older created implicit many to many tables with a unique index instead of a primary key. If migrate dev proposes a change on _AToB tables, follow the hint in the report.
  • SQLite dates. On SQLite, Prisma Client stores DateTime as Unix milliseconds while kastor writes ISO text. Both read back as Date, but comparisons mix badly; the report prints the one time UPDATE that converts old rows.
  • Decimal and BigInt. Prisma returns Decimal objects; kastor returns exact strings for t.decimal(). BigInt stays bigint.
  • Raw query results. $queryRaw counts come back as bigint on Postgres; with $sql, cast in SQL (count(*)::int) or convert in code.
  • relationMode = "prisma". Without foreign keys in the database, relations come from the Prisma schema, and kastor does not emulate referential actions on SQL databases. Add real foreign keys in a kastor migration when you can.

Next, wire the client into your framework: the Next.js App Router guide covers server components, server actions and edge runtimes, and the frameworks page lists the rest.

Questions people ask

Q1Does kastor read schema.prisma?

Yes. npx kastor import prisma reads schema.prisma (or a folder of .prisma files) and writes a TypeScript schema whose table, column, index and constraint names match the database Prisma Migrate created, including implicit many to many tables such as _PostToTag. Add --config to also write kastor.config.ts for the datasource.

Q2Will I lose my Prisma migration history?

No. npx kastor migrate baseline --from-prisma prisma/migrations records every Prisma migration that _prisma_migrations marks as finished, under prisma/<folder> in kastor's own table, plus one baseline migration. The Prisma files and the _prisma_migrations table are not touched.

Q3Do I have to rename prisma.user to something else?

No. The import names each table after its model in camelCase, so prisma.user.findMany becomes db.user.findMany. Rename the exports later if you prefer plural names.

Q4What replaces Prisma middleware and client extensions?

kastor has no extension API. Query logging and timing use the log option, soft delete is a table option, computed fields are functions over the row type, and custom model methods are functions in a module. Most extensions turn into less code.

Q5Can Prisma and kastor run at the same time?

Yes, on the same database, while you move modules. Each has its own connection pool, so lower both pool sizes, and keep only one tool creating migrations: after the baseline, that is kastor.

Q6Do I still need prisma generate?

No. kastor infers every type from the schema file with the TypeScript compiler, so CI runs the type check and npx kastor migrate deploy with no generate or postinstall step.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.