Migrating from Prisma
Turbine is a Postgres-native TypeScript ORM with a Prisma-inspired API. Compared to Prisma you get one runtime dependency (pg), edge/serverless without an extra adapter, and a code-first schema with no .prisma DSL.
Two ways to migrate#
They compose: start with the adapter, port modules to native calls over time.
| Compat adapter | Native port | |
|---|---|---|
| What you run | turbine migrate-from-prisma, then wrap the client in createPrismaCompatClient | Rename include to with, re-point imports |
| Call sites | Unchanged. prisma.user.findMany({ include }) keeps working | Rewritten to db.users.findMany({ with }) |
| Time to first green build | Hours | Proportional to your call-site count |
| Ceiling | A documented set of Prisma features it will not translate | The full Turbine API |
The adapter is a runtime shim, not a codemod: a PrismaClient-shaped surface over a TurbineClient, translating arguments in and results out. It never edits your source.
On either path, run npx turbine doctor before you benchmark (next), and read Silent value differences.
Before anything else: run doctor#
Prisma does not create an index on a relation's foreign key on Postgres. A @relation scalar field is unindexed unless you add @@index yourself. Prisma loads relations by batching (one WHERE fk IN (ids) query per relation), which pays a missing FK index only once, so most Prisma schemas ship without FK indexes and never notice.
Turbine's join plan probes the child table once per parent row with a correlated subquery. On an indexed FK that is an index seek per parent; on an unindexed FK it is a full table scan per parent, multiplied by the parent count. The missing index that was invisible under Prisma becomes the whole query.
The fix is one command. Measured on a production-shaped dataset (659 parents, a 357K-row child table), adding the index took the correlated plan from 17.8 s to 62 ms, a ~290x difference:
| Strategy | Time |
|---|---|
| Correlated, FK unindexed | 17.8 s |
Batched (WHERE fk IN (…)) | 92 ms |
| Correlated, FK indexed | 62 ms |
Do not read the last two rows as "correlated beats batched once indexed": on a to-one relation over a local socket the batched loader wins from a few dozen parent rows upward, and the gap widens with parent count (see The real tradeoff). That is why 'auto' switches a to-one relation to the batched loader above autoToOneJoinMaxRows (default 1000).
The real tradeoff#
The two plans trade a different cost:
- Correlated (
'join') is one round-trip, but the subquery re-evaluates once per parent row, so its cost scales with parent count even on a perfect index seek. The shipped per-parent constant is 0.0007 ms (AUTO_JOIN_PENALTY_MS_PER_ROW), measured on PostgreSQL 17 over ahasOneon aUNIQUEFK; it came out effectively identical (0.000711 ms and 0.000717 ms) on two links whose round-trip times differ by 23x, so it is a property of the plan, not the wire. - Batched is two round-trips (base query, then
WHERE fk = ANY($1)), each a single keyset lookup whose cost is essentially flat in parent count.
The crossover is roundTripMs / 0.0007. Every row below is that division, not a separate measurement:
| Deployment | Round trip | Correlated wins below roughly |
|---|---|---|
| Local socket / same host | ~0.03 ms | 43 parent rows |
| Loopback TCP | ~0.12 ms | 170 parent rows |
| Same-region managed Postgres | ~1 ms | 1,400 parent rows |
| Cross-region pooled connection | ~35 ms | 50,000 parent rows |
The two measured points agree with the arithmetic: on loopback TCP (0.118 ms) the observed crossover sat between 200 and 400 parent rows, and over a link with 1 ms added per direction (2.683 ms) between 3,000 and 5,000.
The default threshold of 1000 corresponds to a database about 0.7 ms away (AUTO_ASSUMED_ROUND_TRIP_MS), roughly same-region managed Postgres; 'auto' uses it until the process has measured its own round-trip time. On a local socket or very low-latency link, lower autoToOneJoinMaxRows into the tens or pin relationLoadStrategy: 'batched' on wide-parent queries; far away, raise it.
A to-many relation, a wider child row, or a real network moves the crossover, so measure your own shape. Results are deep-equal whichever plan runs, so switching is free.
Right after you generate your client, run:
npx turbine doctor --fixdoctor finds every relation whose child-side FK (or m2m junction key) lacks a covering index; --fix writes a migration adding them. Review and apply with npx turbine migrate up. See CLI → doctor for the report format and the CREATE INDEX CONCURRENTLY note for large tables.
If you can't add the index yet: pick a load strategy#
Until the index migration lands, relationLoadStrategy: 'batched' reproduces Prisma's loading pattern: one WHERE fk = ANY(...) follow-up per relation, paying a missing index only once. Output is deep-equal to the default.
const db = new TurbineClient({
connectionString: process.env.DATABASE_URL,
relationLoadStrategy: 'batched',
});Turbine picks a strategy per relation unless you pin one:
relationLoadStrategy | What it does | When |
|---|---|---|
'auto' (default) | Correlated join per relation, with a batched follow-up for any relation whose foreign key is provably unindexed, so a missing index degrades one relation, not the whole query. Logs a once-per-relation dev note when the fallback engages. | Leave it, especially on a schema you haven't fully indexed yet. |
'join' | Always the single-statement correlated json_agg: one round-trip for the whole with tree. | Every FK is indexed and you want one round-trip. |
'batched' | Always one flat WHERE fk = ANY(...) per relation, stitched in memory. Prisma's loading pattern. | Reproduce Prisma exactly, or a huge result set where flat rows beat nested JSON. |
'flatten' (0.50) | Compiles eligible to-one relations to a LEFT JOIN in the same statement: one round-trip, no correlated re-evaluation, no client-side stitching. Ineligible relations silently fall back to a correlated subquery. | One round-trip is a hard requirement and the relations are to-one. See Load strategies for the eligibility rules. |
An explicit 'join', 'batched' or 'flatten' (at the client or on a single query) always wins over 'auto'. An endpoint that fans out to very large child sets (megabyte-scale JSON per request) can profile faster on 'batched' even with healthy indexes; pin it there if measurement says so.
Ordering caveat.
'auto'can load some relations via the batched path, so an unordered child array's order can differ from the pure-join path (order underjson_aggwas never guaranteed to begin with). AddorderByto anywithblock code depends on, or enablestableRelationOrder.
Silent value differences: check these first#
Five differences hand back a value of a different shape or a different row set without throwing. Everything else on this page either errors loudly or changes a type your compiler catches. They apply on both migration paths; grep for them before you port.
| What | Prisma returns | Turbine returns | Why it bites |
|---|---|---|---|
Decimal / numeric column | a Decimal instance (decimal.js) | a string | total.plus(x), .toNumber(), and Number(total) * qty all change meaning. Arithmetic on the string silently coerces through JS floats, the thing numeric exists to prevent. |
BigInt / int8 column | a JS bigint | a number, or a string above Number.MAX_SAFE_INTEGER | The type is a union in practice. id + 1n breaks at the type level (loud); id > cutoff compares a string lexicographically past 2^53 (silent). |
select / include on a write (create, update, upsert, delete) | only the selected fields, plus the included relations | the full row, no relations | Dropped without error. Extra columns leak into anything that spreads the result into a response body; re-read with findUnique if you need a projection. |
aggregate() with orderBy, take, skip, or cursor | applies them | ignores them (they are honored on groupBy, not on aggregate) | An aggregate over what you thought was a 10-row window is an aggregate over the whole matching set. |
where on a JSON column with a pathless equals | strict deep equality | containment (@>) | { meta: { equals: { plan: 'pro' } } } also matches { plan: 'pro', seats: 5 }; equals on JSON is the same operator as contains. Add a path for exact scalar equality, or filter in application code for whole-document equality. |
Take the two column-type rows seriously in a billing, ledger, or metering schema: both are deliberate, and neither necessarily surfaces in a test suite.
The migration toolkit#
Two pieces: a CLI command that reads schema.prisma and resolves it against your live database, and a runtime adapter serving a PrismaClient-shaped API from the map it emits.
turbine migrate-from-prisma#
DATABASE_URL=postgres://... npx turbine migrate-from-prisma --schema prisma/schema.prismaIt parses schema.prisma with a zero-dependency subset parser (neither prisma nor @prisma/client needs to be installed), introspects your database, and resolves every model, field, relation, enum, and compound unique. It writes three things into your generate output directory (default ./generated/turbine):
prisma-migration-report.md: per-model resolution, the Many-to-many relations (audit these call sites) section (Prisma field name, Turbine relation name, junction table), detected implicit-m2m junction tables, enums, unresolved items, and parser notes. Read it first: it is the list of things that will not translate.prisma-map.ts: the typedPRISMA_MAPthat drives the adapter. Every name in it was proven to exist during introspection.- The standard generated client (
types.ts,metadata.ts,index.ts), exactly asturbine generatewrites it.
| Flag | Effect |
|---|---|
--schema <file> | Path to schema.prisma (default prisma/schema.prisma). In this command only, --schema names the Prisma file, not the Postgres namespace the rest of the CLI means by it. The namespace is fixed to public here. |
--url, -u <url> | Connection string, unless --no-db. Falls back to DATABASE_URL. |
--out, -o <dir> | Output directory (default ./generated/turbine). Must resolve inside the directory you ran the command from, or the run exits 1 without writing anything. See The --out directory guard before scripting this into a temp dir. |
--no-db | Parse-only. Writes the report, skips introspection, and emits no prisma-map.ts and no client. Useful for auditing a schema before you have a database to point at. |
--allow-partial | Exit 0 even when items are unresolved. By default an unresolved item exits 1 so CI fails loudly. |
--if-db | Exit 0 without doing anything when no connection string resolves, instead of failing. For the postinstall hook below, where a build image legitimately has no database. |
--no-timestamp | Omit the generated-at lines, for byte-identical regeneration. |
An unresolved item never blocks the client (it is generated from live introspected metadata); only the map entry is missing, so the adapter will not know that one Prisma name.
Keeping the map current#
prisma-map.ts is a snapshot. Once your schema moves past it, the adapter keeps translating the names it has: a new model is not on the compat client, and a renamed field is quietly absent from results. Two things address this; use both.
Regenerate where you already regenerate. The command belongs next to prisma generate:
{
"scripts": {
"postinstall": "prisma generate && turbine migrate-from-prisma --if-db"
}
}--if-db makes that safe in postinstall: a build image with no DATABASE_URL prints one line and exits 0, keeping the committed prisma-map.ts, instead of failing the install. Keep the map in version control and regenerate it in the same commit as any schema.prisma change, like a lockfile. To have CI enforce that, run against a real database and fail on a dirty tree:
turbine migrate-from-prisma --no-timestamp
git diff --exit-code generated/turbine/prisma-map.ts--no-timestamp is required there: without it every run rewrites the generated-at line.
The adapter tells you when the map is stale. Since 0.60, migrate-from-prisma records a fingerprint of the schema.prisma it read into the map, and createPrismaCompatClient compares it against the file on disk once per process at startup:
[turbine] prisma-compat: prisma/schema.prisma has changed since prisma-map.ts was generated,
so any model, field, relation or compound-unique added or renamed since then is missing
from the compat client. Re-run: turbine migrate-from-prismaIt is a development aid: skipped under NODE_ENV=production, silent when schema.prisma is missing or there is no filesystem, asynchronous and unawaited, printed at most once per process, and unable to fail your app or your build. A map without a fingerprint (pre-0.60, or assembled by hand) is skipped.
The fingerprint ignores exactly the differences a checkout can introduce on its own:
| Difference | Reported as drift? |
|---|---|
| CRLF or lone-CR line endings | No |
| A leading byte-order mark | No |
| Whitespace or blank lines at the end of the file | No |
| Trailing whitespace on an individual line | Yes |
| An edited comment | Yes |
The last two count as drift because nothing in a checkout produces them: someone edited the file.
Connection string. Resolution order: --url, then DATABASE_URL, then url in turbine.config.ts, then the schema's datasource block including its env("...") indirection (url first, then directUrl). An explicit --url is never overridden by a schema file; if nothing yields a URL, the error names the exact variable the datasource asked for.
Auditing your many-to-many call sites#
The many-to-many rules changed in 0.50 (see Notable differences), so a port needs to know which call sites are affected. Do not grep for the Turbine relation names: compat call sites use the Prisma field names, so that audit matches nothing.
The report's "Many-to-many relations (audit these call sites)" section lists every resolved m2m relation with both names and the junction table:
| Prisma call site | Turbine relation | Junction table |
|---|---|---|
Post.tags | tags | _PostToTag |
and closes with a ready-to-run grep over the Prisma field names:
grep -rEn "\b(tags)\b" srcRun it, then review every write whose data nests one of those fields. In --no-db mode the section says the list needs a database run, because m2m relations are recognized from the live database.
createPrismaCompatClient#
turbine-orm/prisma-compat wraps a TurbineClient in Prisma's surface. It is a pure TypeScript shim: zero new dependencies, never imported by Turbine core.
import { TurbineClient } from 'turbine-orm';
import { createPrismaCompatClient } from 'turbine-orm/prisma-compat';
import { SCHEMA } from './generated/turbine/metadata.js';
import { PRISMA_MAP } from './generated/turbine/prisma-map.js';
const db = new TurbineClient({ connectionString: process.env.DATABASE_URL }, SCHEMA);
export const prisma = createPrismaCompatClient(db, PRISMA_MAP);Swap that in for new PrismaClient() and existing call sites keep working:
// unchanged from your Prisma codebase
const users = await prisma.user.findMany({
where: { email: { contains: '@acme.com' } },
include: { posts: { orderBy: { createdAt: 'desc' }, take: 5 } },
});Model delegates are registered under both spellings, the Prisma model name (prisma.User) and Prisma's generated client property (prisma.user), so either style resolves to the same delegate.
Implicit junction tables get a delegate too (since 0.50). Prisma's schema has no model for an implicit m2m junction, so PRISMA_MAP has no entry for one; every junction in the Turbine metadata is exposed under its raw table name, with identity field mapping (no renames, no relations), on the client and inside $transaction:
// The delegate is keyed by the junction's raw table name, and its fields are
// that table's own columns in Turbine's usual camelCase spelling.
await prisma.$transaction(async (tx) => {
const junction = (tx as Record<string, any>)._PostToTag;
await junction.createMany({ data: [{ postId: 1, tagId: 7 }] });
});A junction name that collides with a real Prisma model, or with a table a model already maps to, is skipped: the model wins its own key. The delegates are real at runtime but absent from the generated PrismaCompatClient type (there is no Prisma model to type them from), hence the cast above. The report's junction-tables section lists each junction's columns.
What it translates: include to with; select split into scalar selection plus relations; field and relation renames in both directions; take/skip to limit/offset; cursor pagination; compound-unique selectors including custom @@unique(name:) names; $transaction in both the callback and lazy-array-batching forms; $queryRaw / $executeRaw and their Unsafe variants with Prisma.sql-style fragment flattening; createMany({ skipDuplicates }) to ON CONFLICT DO NOTHING; _count objects keyed back to Prisma relation names; to-one relations surfaced as object | null; Prisma's client-side defaults emulated on write (see Behaviors that match Prisma).
Inside $transaction, the tx client carries the raw-SQL surface too. tx.$queryRaw, tx.$executeRaw and their Unsafe variants run on the transaction's own connection, so a migrated block that mixes ORM calls with raw statements stays atomic. If the underlying transaction client cannot execute raw SQL, the adapter throws ValidationError rather than falling back to a pool connection outside the transaction. tx also exposes delegates under both spellings (tx.User, tx.user); the lowercase alias is skipped when it would shadow a real model name.
Two options. prismaErrorCodes: true sets .code on thrown TurbineErrors to the nearest Prisma code (P2002 unique violation, P2025 not-found) without pretending to be instanceof PrismaClientKnownRequestError; left off, Turbine's TURBINE_E0NN codes are preserved. stablePkOrder: true orders every to-many with relation lacking an explicit orderBy by the target's primary key ascending, close to how Prisma's relation rows tend to arrive; a per-relation orderBy wins.
One Turbine-only argument passes straight through: includePii: UNSAFE on a read, a groupBy, or an aggregate. Prisma has no PII concept, so this is the only way a compat call site can read columns tagged pii: true.
Client extensions: $extends#
$extends returns a new client. The one you called it on keeps working unchanged, and the returned client is itself extendable, so chains work as they do in Prisma:
const extended = prisma.$extends({
name: 'helpers',
client: {
async $healthCheck() {
return true;
},
},
model: {
User: {
async findByEmail(email: string) {
return prisma.User.findFirst({ where: { email } });
},
},
$allModels: {
async countAll() {
return 0;
},
},
},
});
await extended.$healthCheck();
await extended.User.findByEmail('a@b.com');
await extended.post.countAll(); // $allModels member, lowercase spellingFour details:
- Both spellings get the members. A
modelkey may beUseroruser; the members land on the one delegate both resolve to.$allModelsapplies to every delegate. thisis the extended delegate, a shallow copy carrying$name(the Prisma model name; runtime-only, so reading it needs a cast).Prisma.getExtensionContextis exported and is the identity function.modelmembers survive$transaction. The delegates handed to the callback carry the same members.clientmembers are deliberately not on the transaction client. They usually close over the base client, so reaching one throughtxwould run outside the transaction; absent, it is aTypeErrorinstead of a silent correctness bug.
The callback form is Prisma's, unchanged: prisma.$extends(fn) is fn(prisma). Prisma.defineExtension is a type-preserving passthrough.
What throws. Everything the adapter cannot honour is refused at $extends time, so a wrong assumption fails at boot:
| Component | Result |
|---|---|
client, model | Supported |
query (interception) | UnsupportedFeatureError (TURBINE_E017), pointing at client.$use on the underlying TurbineClient as the interception seam. $allOperations has no equivalent |
result (computed fields) | UnsupportedFeatureError (TURBINE_E017). Prisma implements it by rewriting the projection, which cannot be layered on the PII projection rules (a needs field on a pii-tagged column would arrive undefined). Compute it in application code, or add a generated column |
| Anything else (Accelerate, Pulse, read-replica extensions) | UnsupportedFeatureError naming the component |
query and result are also declared never on the extension type, so passing one is a compile error before it is ever a runtime one. Two more shapes throw ValidationError (TURBINE_E003): a client member whose name would shadow a delegate or a client-level method, and a model key that names no model on this client (the message lists the known models).
Turbine-native query options#
Prisma's argument shapes have no equivalent for a handful of Turbine-only query options, so the adapter forwards them verbatim when you pass them:
const rows = await compat.Order.findMany({
where: { tenantId },
orderBy: { id: 'asc' },
take: 20,
forceCustomPlan: true, // Turbine-only, forwarded to the core client
});The full set, by operation: forceCustomPlan, warnOnUnlimited, skipGlobalFilters, allowFullTableScan, timeout, includePii, stableRelationOrder, optimisticLock and groupBy's distinctOn. The two that name schema fields (optimisticLock.field, distinctOn.columns) are translated through the same name map as the rest of the call, so you write them in Prisma field names like everything else.
The option surface is runtime data (src/query/option-surface.ts) checked against the core arg interfaces, so a new core option fails the adapter's build until classified. Before 0.57.0 these options were silently dropped; on upgrade, skipGlobalFilters takes effect where it was inert, relationLoadStrategy: 'query' maps to 'batched', and limit on updateMany / deleteMany throws UnsupportedFeatureError (TURBINE_E017) instead of being ignored.
Unknown query options warn#
A key that is neither a Prisma argument nor a Turbine option logs one dev-only line per model, operation and key:
[turbine] prisma-compat: unknown option "customPlan" in User.findMany(), it is ignored. Did you mean "forceCustomPlan"?
[turbine] prisma-compat: "limit" is Turbine's spelling and is ignored here; prisma-compat takes Prisma's "take". (User.findMany)The second form covers Turbine's spelling written into a Prisma-shaped call (limit/with/offset for take/include/skip). It is a warning, never a throw, silent under NODE_ENV=production, once per key per process; every legitimate Prisma argument is on the known set, including ones Prisma itself accepts and ignores (count({ take })).
What the adapter does not do#
Absent entirely. $use and $on are not implemented and not present on the returned object; calling one is a plain TypeError, not a typed Turbine error. Turbine's own middleware is db.$use on the underlying TurbineClient, with different semantics: it runs after SQL generation and cannot rewrite a query. $extends is implemented for two of Prisma's four components, see Client extensions.
Present but inert. $connect() and $disconnect() resolve immediately without doing anything. The TurbineClient owns the pool, so shut down with db.disconnect(); a migration that relies on $disconnect() closing connections (tests, serverless teardown) will leak the pool.
count() forwards only where, plus the Turbine-native options above. Prisma's select, cursor, take, skip, orderBy, and distinct on count are dropped without error, so count({ take: 10 }) returns the count of all matching rows; rewrite those calls.
Throws rather than guessing. Three shapes raise UnsupportedFeatureError instead of returning a subtly wrong result:
- A bare inclusive cursor whose field is not the sort key. Translating it exactly needs the anchor row's sort-key value; see Cursor pagination for which shapes translate and which throw.
- Negative
take(take-from-end). Turbine'slimithas no reverse form. skipinside a nested relationinclude. Turbine'swithclause has no per-relation offset.
Not attempted. Fluent relation chaining (prisma.user.findUnique(...).posts()); instanceof PrismaClientKnownRequestError identity and byte-identical error messages; Prisma.join / Prisma.raw composition beyond plain fragment flattening; Accelerate, Pulse, and the driver-adapter preview features; the MongoDB API; the prisma migrate / prisma db CLI family (Turbine ships its own migrations). createMany({ skipDuplicates }) throws UnsupportedFeatureError on the SQL Server and PowDB engines.
Breaking change in 0.41: unique foreign keys introspect as hasOne#
When a child table's foreign-key columns are exactly covered by a unique constraint or unique index, the relation is one-to-one, and introspection (since 0.41) emits hasOne for the parent side instead of hasMany.
Two things change together: the generated type goes from Child[] to Child | null, and the relation is renamed from the plural child-table name to its singular (profiles becomes profile). A call site that only fixes the shape and keeps the old key gets a RelationError (TURBINE_E005).
// Before 0.41: plural key, array value
const user = await db.users.findUnique({ where: { id: 1 }, with: { profiles: true } });
user.profiles[0]?.bio; // array, always length 0 or 1
// 0.41 and later: singular key, object-or-null value
const user = await db.users.findUnique({ where: { id: 1 }, with: { profile: true } });
user.profile?.bio; // object | nullIf the singular name would collide with an existing field, column, or relation, introspection keeps the plural name and only the shape changes. This matches Prisma's own shape for a @unique back-relation. To keep the old shape while you port:
npx turbine generate --legacy-to-many-uniquesAlso settable as legacyToManyUniques: true in turbine.config.ts. It only affects introspection output, never runtime behavior.
Compound-unique where selectors#
Prisma addresses a multi-column unique constraint through a single synthetic key. Turbine accepts the same spelling, so these call sites port verbatim:
// Prisma and Turbine, identical
await db.members.findUnique({ where: { orgId_userId: { orgId: 1, userId: 7 } } });
// equivalent to
await db.members.findUnique({ where: { orgId: 1, userId: 7 } });Selector names come from your metadata: the composite primary key, each composite unique constraint, and each composite unique index. Two spellings are registered per column set: the underscore join of the camelCase field names (orgId_userId, Prisma's default) and, when it differs, of the raw column names (org_id_user_id).
Two rules keep it unambiguous: a synthetic name that collides with a real field, column, or relation is never registered (the real member wins), and a name two different column sets would share is dropped entirely. A partial unique index never backs a selector, since it only guarantees uniqueness across the rows matching its predicate. Custom @@unique(name:) names are handled by the compat adapter from PRISMA_MAP. Selectors work on the whole findUnique family and in nested-write unique wheres (connect, connectOrCreate, and friends), on every engine including PowDB.
API mapping#
The full mapping for a native port. Skip to Side-by-side to see it working.
| Prisma | Turbine | Notes |
|---|---|---|
prisma.user.findMany | db.users.findMany | Table accessor uses the snake_case table name (camelCased). |
prisma.user.findUnique | db.users.findUnique | Same shape. |
prisma.user.findFirst | db.users.findFirst | Same. |
prisma.user.findFirstOrThrow | db.users.findFirstOrThrow | Throws NotFoundError (TURBINE_E001). |
prisma.user.findUniqueOrThrow | db.users.findUniqueOrThrow | Same. |
prisma.user.create | db.users.create | Same data shape. |
prisma.user.createMany | db.users.createMany | Single INSERT ... UNNEST under the hood. |
prisma.user.update | db.users.update | Supports atomic operators: { count: { increment: 1 } }. |
prisma.user.updateMany | db.users.updateMany | Empty where rejected unless allowFullTableScan: UNSAFE. |
prisma.user.delete | db.users.delete | Same. |
prisma.user.deleteMany | db.users.deleteMany | Empty where rejected unless allowFullTableScan: UNSAFE. |
prisma.user.upsert | db.users.upsert | Same where / create / update shape. |
prisma.user.count | db.users.count | Same. |
prisma.user.aggregate | db.users.aggregate | _sum / _avg / _min / _max / _count. _count: true returns a number; _count: { _all: true } returns { _all: n } (Prisma's shape). |
prisma.user.groupBy | db.users.groupBy | by, where, orderBy, plus _count / _sum / _avg / _min / _max. Same _count shapes as aggregate. |
prisma.$transaction | db.$transaction | Callback form with nested SAVEPOINTs and isolation levels. |
include: { posts: true } | with: { posts: true } | The only renamed key. |
select: { id: true, name: true } | select: { id: true, name: true } | Same. |
where: { name: { contains: 'a' } } | where: { name: { contains: 'a' } } | All operators ported. |
where: { posts: { some: ... } } | where: { posts: { some: ... } } | Relation filters: some / every / none. |
data: { posts: { create: [...] } } | data: { posts: { create: [...] } } | Nested writes map unchanged on to-one and one-to-many relations, in one transaction with a depth cap of 10. Many-to-many supports connect / disconnect / set since 0.50; the rest throw ValidationError. See Nested writes. |
include: { _count: { select: { posts: true } } } | with: { _count: { posts: true } } | No select wrapper. _count: true counts every to-many relation. |
prisma.$queryRaw | db.raw`...` | Typed form: db.sql<T>`...` returns T[] with .one() / .scalar(). |
take: 10 | take: 10 | Works as-is, take is an alias for limit. |
cursor: { id: 99 } | cursor: { id: 99 } | Keyset pagination, same shape, but Turbine's cursor is exclusive: drop any skip: 1 and audit cursors that had none (see Cursor pagination). |
distinct: ['userId'] | distinct: ['userId'] | Same, compiles to DISTINCT ON. |
skip: 20 | offset: 20 | Renamed. |
Schema translation#
Prisma's .prisma schema translates to Turbine's defineSchema() call.
// schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id])
title String
published Boolean @default(false)
viewCount Int @default(0)
createdAt DateTime @default(now())
}// schema.ts
import { defineSchema } from 'turbine-orm';
export default defineSchema({
users: {
id: { type: 'serial', primaryKey: true },
email: { type: 'text', unique: true, notNull: true },
name: { type: 'text', notNull: true },
createdAt: { type: 'timestamp', default: 'now()' },
},
posts: {
id: { type: 'serial', primaryKey: true },
userId: { type: 'bigint', notNull: true, references: 'users.id' },
title: { type: 'text', notNull: true },
published: { type: 'boolean', notNull: true, default: 'false' },
viewCount: { type: 'integer', notNull: true, default: '0' },
createdAt: { type: 'timestamp', default: 'now()' },
},
});Relations aren't declared in Turbine, they're inferred from foreign keys: posts.userId references 'users.id' yields user on Post and posts on User.
Side-by-side#
findMany with nested relations#
// Prisma
const users = await prisma.user.findMany({
where: { orgId: 1 },
include: { posts: { orderBy: { createdAt: 'desc' }, take: 5 } },
orderBy: { createdAt: 'desc' },
take: 10,
});// Turbine, top-level take works as-is; the nested take becomes limit
const users = await db.users.findMany({
where: { orgId: 1 },
with: { posts: { orderBy: { createdAt: 'desc' }, limit: 5 } },
orderBy: { createdAt: 'desc' },
take: 10,
});Atomic update#
// Prisma
await prisma.post.update({
where: { id: 42 },
data: { viewCount: { increment: 1 } },
});// Turbine, identical
await db.posts.update({
where: { id: 42 },
data: { viewCount: { increment: 1 } },
});Both generate view_count = view_count + $1. No extra round-trip.
Transaction#
// Prisma
await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: { email: 'a@b.c', name: 'A' } });
await tx.post.create({ data: { userId: user.id, title: 'Hi' } });
});// Turbine
await db.$transaction(async (tx) => {
const user = await tx.users.create({ data: { email: 'a@b.c', name: 'A' } });
await tx.posts.create({ data: { userId: user.id, title: 'Hi' } });
});Upsert#
// Prisma
await prisma.user.upsert({
where: { email: 'a@b.c' },
create: { email: 'a@b.c', name: 'A' },
update: { name: 'A' },
});// Turbine, identical
await db.users.upsert({
where: { email: 'a@b.c' },
create: { email: 'a@b.c', name: 'A' },
update: { name: 'A' },
});Relation filter#
// Prisma
const active = await prisma.user.findMany({
where: { posts: { some: { published: true } } },
});// Turbine, identical
const active = await db.users.findMany({
where: { posts: { some: { published: true } } },
});Notable differences#
- No
schema.prisma. Code-firstdefineSchema()in a TypeScript module.npx turbine push(fast path) ormigrate create --auto(generates SQL). include→with,skip→offset. That's the full lexical diff.take,cursor, anddistinctwork as-is at the top level (takeis an alias forlimit); inside a nestedwith, the per-relation limit is spelledlimit.- Atomic update operators are first-class.
set,increment,decrement,multiply,divide. All compile to in-place SQL. - Typed errors with codes.
UniqueConstraintError/ForeignKeyError/NotNullViolationError/CheckConstraintErrorcarrycode(TURBINE_E008–E011) andcause.findUniqueOrThrowthrowsNotFoundError(TURBINE_E001) with thewhereattached.DeadlockError/SerializationFailureErrorhavereadonly isRetryable = true as const. - Driver-agnostic edge support. Pass any pg-compatible pool to
turbineHttp(pool, SCHEMA)and the same API runs on Neon, Vercel, Cloudflare Hyperdrive, Supabase. No extra adapter package. - Single runtime dependency.
pgonly. No engine binary, no WASM, no@prisma/client. - Postgres-first, not Postgres-only. Optional SQLite, MySQL, SQL Server, and PowDB engines ship behind subpath exports (
turbine-orm/sqlite,/mysql,/mssql,/powdb) and share the same typed API; a handful of Postgres-only features throw a typedUnsupportedFeatureError(TURBINE_E017) elsewhere, and the schema tooling targets Postgres. See What is actually Postgres-only. - Nested writes, including many-to-many.
create/connect/connectOrCreateon a create, plusdisconnect/set/delete/update/upserton an update, in one transaction with a depth cap of 10. A many-to-many relation supports the junction-only operations (connect,disconnect,set, since 0.50); the rest throwValidationError(TURBINE_E003), because a junction's payload columns have no safe default. Port those call sites to a target write plus aconnect, or to junction-table writes inside a$transaction. See Nested writes and Auditing your many-to-many call sites.
Cursor pagination#
Turbine's cursor is exclusive: the anchor row is not returned. That is keyset semantics (WHERE pk > $cursor) and removes Prisma's skip: 1 boilerplate. The two paths handle the difference differently, so read the one you are on.
On the native port
- Prisma
cursor: { id }+skip: 1(the common exclusive idiom) becomes justcursor: { id }. Drop theskip. - Prisma
cursor: { id }with noskipis inclusive. Ported verbatim it silently loses the anchor row; nothing warns you. If you relied on it, fetch the anchor separately or start the cursor at the previous row.
On turbine-orm/prisma-compat
The adapter either reproduces Prisma's inclusive semantics exactly or refuses the call; it never silently drops the anchor row.
cursor+skip: ntranslates exactly, to an exclusive cursor plusoffset: n - 1.- A bare inclusive
cursortranslates exactly (an inclusivegte/ltekeyset predicate merged into thewhere) when it names a single field that is either the singleorderByfield or, with noorderByat all, the single-column primary key: the shapes where the anchor's sort-key value is known from the cursor itself. - Every other bare inclusive cursor throws
UnsupportedFeatureError(TURBINE_E017): a multi-field cursor, a cursor field that is not the sort key, a cursor on a non-PK field with noorderBy. Translating those needs the anchor row's sort-key value, which the adapter does not have. The fix is in the message: order by the cursor field, or pair the cursor withskip: 1.
Relation array order#
Relation arrays have no guaranteed order unless the with block passes orderBy. Prisma makes no guarantee either, but its batched loader usually surfaces insert/PK order and apps quietly depend on it; under Turbine's json_agg loader (and 'auto''s batched fallback) the order can differ. Add orderBy: { id: 'asc' } (or the real sort key) to any with clause code depends on, or set stableRelationOrder (client-wide or per query, off by default) for a deterministic order; a per-relation orderBy always wins.
Behaviors that match Prisma#
timestampanddatecolumns are UTC on both sides. Turbine reads zone-lesstimestampanddateas UTC (Prisma's convention) and, since v0.52, writes theDatevalues bound by writes and filters as UTC too, so a migrated app gets identical instants.datejoined the UTC read half in v0.54; earlier versions read it at the process's local midnight.utcTimestamps: falseopts out, per process rather than per client: the first client settles the pg type parsers, and a later client asking for the opposite throwsValidationError(TURBINE_E003) at construction. Full detail, including external pools and what changes on upgrade: Zone-less columns.- Paginated reads are ordered by the primary key. Prisma appends an implicit
ORDER BY <primary key> ASCto afindManywithtake/skip; since 0.50 the adapter does the same (every column of a composite key, in declaration order). An explicitorderBywins, a model with no primary key is left alone, and there is no off switch: matching Prisma is the adapter's contract. Turbine core still emits a bareLIMITunless you setimplicitPkOrdering: true. - Bare to-one relation filters work without
is.where: { vendor: { name: { contains: 'x' } } }works exactly like Prisma;is: null/isNot: nullcompile toNOT EXISTS/EXISTS. timecolumns through the compat adapter. Postgrestimevalues surface as aDateon 1970-01-01 UTC, Prisma's epoch-day convention, so.getHours()-style call sites keep working; Turbine core keeps the driver's rawHH:MM:SSstring.- Client-side defaults are emulated. Prisma fills
@default(uuid()),@default(cuid()), and@updatedAtin the client, so those columns usually have no database default. The map records them (clientDefaults); the adapter fills them oncreate/createManyand touches@updatedAtonupdate/updateMany/upsert.@default(now())is carried only when introspection finds no database default. upsertfollows Prisma's lookup-first semantics. Coreupsertcompiles a single atomicINSERT ... ON CONFLICTkeyed on the create data's unique values. When thewherevalues equal the create values (the common idiom) that matches Prisma and the adapter passes through; when they differ, the adapter looks up bywhereinside a transaction, updates the found row, else insertscreate. Calling coreupsertdirectly with awhere/createkey mismatch silently inserts a second row.
Migration checklist#
The steps below are the native port. For the adapter path: install turbine-orm, run npx turbine doctor --fix, run npx turbine migrate-from-prisma, read the report, swap new PrismaClient() for createPrismaCompatClient(db, PRISMA_MAP), then work through what the adapter does not do. Steps 1, 2, and 9 still apply.
npm install turbine-orm && npm uninstall @prisma/client prisma.- Fix FK indexes before you benchmark. Run
npx turbine doctor --fix, thennpx turbine migrate up, or setrelationLoadStrategy: 'batched'on the client until the migration lands (see above). - Write
schema.tsmirroring your.prismamodels (or runnpx turbine pullto introspect your live DB). npx turbine generate, writes./generated/turbine/{types,metadata,index}.ts.- Rewrite the call sites (the compat adapter serves Prisma's surface at runtime; nothing rewrites source for you). Find/replace:
prisma.→db.- Singular model names → plural snake-camelCase table names (
prisma.user→db.users) include:→with:skip:→offset:take:stays as-is at the top level; inside awithblock, rename it tolimit:cursor:stays as-is, but delete anyskip: 1next to it, and audit cursors that had noskip, since Turbine's cursor is exclusive (see Cursor pagination).
- Replace
import { PrismaClient } from '@prisma/client'withimport { turbine } from './generated/turbine/index.js'. - Port raw SQL from
prisma.$queryRawtodb.raw`SELECT ...`; for typed results usedb.sql<T>`SELECT ...`(the drop-in for Prisma's TypedSQL:T[]with.one()and.scalar()). - Update your error-handling to the typed classes (or keep catching by message during transition).
- Delete
schema.prismaand theprisma/directory once the build passes.
Connection URL note. If your
DATABASE_URLcarriessslmode=require,pgprints aSECURITY WARNINGat startup: that mode currently aliases full verification but changes meaning inpgv9. Usesslmode=verify-fullto keep full certificate verification (covers most managed Postgres, whose certs chain to a public CA);uselibpqcompat=true&sslmode=requireto opt into the future libpq semantics now (encryption without certificate verification); or dropsslmodeand passsslconfig through the pool onTurbineConfig.
See also#
- Schema & Migrations,
defineSchema, DDL, introspection. - API Reference, every method, operator, option.
- Relations, one-to-many, many-to-many, filters.
- Typed Errors, full hierarchy with SQLSTATE mapping.
- Serverless, Neon, Vercel, Cloudflare Hyperdrive.