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.
npm install kastor-orm pgStep 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("...").
npx kastor import prisma --schema prisma/schema.prisma --out src/db/schema.ts --configHere is a typical schema with an enum, a one to one, a one to many and an implicit many to many relation:
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:
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:
// 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.prisma | kastor schema |
|---|---|
model User | export 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 types | t.string(200), .nativeType('...') |
enum Role | t.enum(Role, 'Role') with a const array |
Decimal, BigInt, Json, Bytes | t.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 sides | the _PostToTag table plus many(tag, { through }) |
@@index, @@unique, @@id | index(), 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:
npx kastor migrate baseline --from-prisma prisma/migrations
npx kastor migrate dev
npx kastor migrate statuskastor / 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
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 = prismaimport 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 = dbThe 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:
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
}
}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 Client | kastor |
|---|---|
prisma.user.findMany, findFirst, findUnique, ...OrThrow | db.user.findMany, findFirst, findUnique, ...OrThrow |
where with equals, in, lt, contains, mode: 'insensitive' | the same keys |
AND, OR, NOT, some, every, none, is, isNot | the same keys |
include, select, _count | the same, loaded in one statement per call |
orderBy, take, skip, cursor, distinct | the same, plus findPage |
create with nested create, connect, connectOrCreate | the same |
update with increment, set, nested disconnect, delete | the same |
upsert, createMany with skipDuplicates, createManyAndReturn | the same |
updateMany, delete, deleteMany, count, aggregate, groupBy with having | the same |
$transaction([...]), $transaction(async (tx) => ...) | the same, with isolationLevel |
$queryRaw`...`, $executeRaw`...` | $sql`...`, $execute`...` |
Prisma.sql, Prisma.join, Prisma.raw | sql`...`, sql.join, sql.raw |
$queryRawUnsafe(text, ...values) | $sql with sql.raw for trusted identifiers only |
error P2002, P2003, P2025 | UNIQUE_VIOLATION, FOREIGN_KEY_VIOLATION, NOT_FOUND |
Prisma.UserGetPayload<...>, generated User | InferSelect<typeof user>, or the return type of your query |
$connect, $disconnect | not 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 did | The closest kastor feature |
|---|---|
| Log queries and time them | the 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 writes | the function that writes, or a JS default on the column |
| Multi tenant filters, row level security | a typed where object you spread, or Postgres RLS set inside $transaction |
| Retry on serialization failures | catch SERIALIZATION_FAILURE and run the transaction again |
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:
- 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
@updatedAtinto.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()andulid()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 devproposes a change on_AToBtables, follow the hint in the report. - SQLite dates. On SQLite, Prisma Client stores
DateTimeas Unix milliseconds while kastor writes ISO text. Both read back asDate, but comparisons mix badly; the report prints the one timeUPDATEthat converts old rows. - Decimal and BigInt. Prisma returns
Decimalobjects; kastor returns exact strings fort.decimal(). BigInt staysbigint. - Raw query results.
$queryRawcounts come back asbiginton 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.