Compatibility
What's supported, and how VibORM intentionally differs from Prisma
Compatibility
VibORM’s API is Prisma-inspired, but it is not a drop-in replacement. This page lists what’s supported, the operations that intentionally differ, and the handful of behaviors that vary by database.
Everything in the API works the same on every database unless noted under Database differences.
Operations
| Operation | Status | Notes |
|---|---|---|
findUnique / findUniqueOrThrow |
✅ | Requires a unique selector, returns T | null (or throws). The selector may also carry non-unique scalar and relation filters and AND/OR/NOT (Prisma ≥ 4.5) — see Unique selectors. |
findFirst / findFirstOrThrow |
✅ | Filtering, ordering, pagination, select, include. |
findMany |
✅ | Filters, relation filters, ordering, cursor pagination, negative take, distinct, select, include. |
create |
✅ | Scalar data and nested writes. |
createMany |
Different | Returns { count }, or the created rows when the call carries a scalar-only select; Prisma’s separate createManyAndReturn does not exist. Rows accept the ordinary create data shape, including nested relations, except that a direct polymorphic field stays connect-only. Relation-bearing rows execute left to right. Interactive drivers use one transaction; a no-transaction driver with native atomic batch uses committed segments. With skipDuplicates, a conflicting root suppresses its complete subtree. The root is isolated as one segment; only a member that must write before that root refuses pre-effect, because those earlier effects could not be undone after a skip. A select refuses rather than answer short when a later row moves an earlier row’s primary key. |
update |
✅ | Requires a unique where and throws when missing. Extra scalar and relation filters are allowed in it; an excluded row is a NotFoundError, never a silent no-op. |
updateMany |
Different | Returns { count }, or the updated rows when the call carries a scalar-only select; Prisma’s separate updateManyAndReturn does not exist. data accepts the ordinary update surface, relations included, which Prisma rejects outright. A relation-bearing call captures the matching roots and runs one ordinary selected update per row. Interactive drivers use one transaction; a no-transaction driver with native atomic batch uses committed segments. count is the captured match count rather than the provider’s affected-row total. A child-held connect/connectOrCreate/set naming an existing target is refused before any write when more than one root matched. A select refuses rather than answer short when a later row removes an earlier row’s key. Prisma 6’s limit is supported, with Prisma’s caveat: it caps how many rows are affected, not which. |
upsert |
✅ | Requires unique where, create, and update. An extra filter that excludes an existing row sends the call down the create branch, whose unique violation surfaces unretried. |
delete |
✅ | Requires a unique where and throws when missing. Extra scalar and relation filters are allowed in it; an excluded row is a NotFoundError and survives. |
deleteMany |
Extension | Returns { count }, or the deleted rows when the call carries a select (scalar-only). Prisma has no returning deleteMany. Prisma 6’s limit is supported. |
count / aggregate / groupBy |
✅ | where, aggregate selections, having, ordering, and pagination. Fixed-decimal _min/_max/_sum/_avg return exact Decimal values on every provider. |
exist |
Extension | Returns boolean. Prisma has no equivalent (note: exist, not exists). |
$queryRaw / $executeRaw |
✅ | Tagged templates that bind every interpolation, exactly like Prisma’s. $queryRaw returns T[], $executeRaw returns the affected count. See Raw SQL. |
$queryRawUnsafe / $executeRawUnsafe |
✅ | Prisma’s signatures: a statement string plus positional parameters. |
Unique selectors
findUnique, findUniqueOrThrow, update, delete and upsert take Prisma
≥ 4.5’s extended unique where: at least one unique discriminator (a unique
scalar, or a complete compound constraint) plus any non-unique filters — scalar
and relation — and AND / OR / NOT. Full semantics, per operation, are in
Narrowing a Unique Lookup.
Nested to-many update, upsert, and delete targets take the extended
unique where too — a superset of Prisma in those positions. A nested to-one
update instead accepts an ordinary non-unique precondition on the one current
member; to-one upsert and boolean delete have no selector.
One deliberate divergence, and it covers only the LINK selectors:
| VibORM | Prisma | |
|---|---|---|
Relation-link target selectors (connect, disconnect, set, connectOrCreate.where) and cursor |
Unique discriminators only | Extended there too |
Relation filters
| Relation | Filters | Notes |
|---|---|---|
Ordinary to-one (s.toOne over one model) |
is, isNot |
Filters against the related model’s where. Optional relations also accept bare null and null inside is/isNot. |
Ordinary to-many (s.toMany over one model) |
some, every, none |
every also matches records with no related rows; combine with some: {} when you need at least one. |
| Direct polymorphic to-one | type with is or isNot; optional storage also accepts null, { is: null }, and { isNot: null } |
The discriminator selects the target model before its filter is parsed. |
| Polymorphic inverse | Ordinary to-one or to-many operators | The declared inverse cardinality owns the public filter shape; every predicate still matches both stored type and identity. |
Nested writes
Nested writes are supported inside create, update, and both branches of
upsert. They are also supported inside the two root bulk writes: a
createMany row takes the create matrix below and updateMany.data takes the
update matrix, since each is compiled as an ordinary record write. Support also
depends on where the relation is stored:
- Parent-held to-one: the current record holds the foreign key.
- Child-held to-one: the related record holds the foreign key and the edge is singular.
- Child-held to-many: the related records hold the foreign key.
- Junction: a many-to-many join table holds membership.
Inside create
This matrix also applies to the create branch of a top-level upsert.
| Nested operation | Parent-held to-one | Child-held to-one | Child-held to-many | Junction |
|---|---|---|---|---|
create |
✅ | ✅ | ✅ | ✅ |
createMany |
— | — | ✅ | ✅ |
connect |
✅ | ✅ | ✅ | ✅ |
connectOrCreate |
✅ | ✅ | ✅ | ✅ |
upsert |
— | — | ✅ Extension | ✅ Extension |
Create-time nested upsert is a VibORM extension. It performs a global unique
lookup. A found row is adopted into the new relation and updated; a missing row
is created and related. Prisma has no nested upsert in a create input.
Inside update
This matrix also applies to the update branch of a top-level upsert.
| Nested operation | Parent-held to-one | Child-held to-one | Child-held to-many | Junction |
|---|---|---|---|---|
create |
✅ | ✅ | ✅ | ✅ |
createMany |
— | — | ✅ | ✅ |
connect |
✅ | ✅ | ✅ | ✅ |
connectOrCreate |
✅ | ✅ | ✅ | ✅ |
disconnect |
✅ Clearable membership | ✅ Clearable membership | ✅ Clearable membership | ✅ |
delete |
✅ Empty slot allowed | ✅ Empty slot allowed | ✅ | ✅ |
set |
— | — | ✅ See required-membership rule below | ✅ |
update |
✅ | ✅ | ✅ | ✅ |
updateMany |
— | — | ✅ | ✅ |
upsert |
✅ | ✅ | ✅ | ✅ |
deleteMany |
— | — | ✅ | ✅ |
An empty slot and a clearable membership are different facts. For an owning to-one they normally coincide. A non-owning singular slot is derived nullable — the membership lives on the other row and may simply be missing — so deleting its child is valid; disconnecting must preserve that child and therefore still requires nullable child-side storage.
Direct polymorphic fields follow the parent-held to-one availability above,
with a required type in target-specific payloads. Polymorphic inverse slots —
singular s.toOne and plural s.toMany — follow the corresponding child-held
column.
Their membership is the exact stored (type, identity) pair. See the
polymorphic relation reference for their
payload shapes.
Ordinary and polymorphic child-held to-many set accept required membership.
They may retain existing members and adopt new ones, but reject when any current
exact member would depart. disconnect requires clearable membership and its
key is absent otherwise.
On a many-to-many relation, scalar-only createMany starts with row-local
routing; homogeneous leaf and adopt runs stay grouped. Each member is classified
in input order, so one nameable row and one unnameable sibling can take different
routes. skipDuplicates for that member depends on whether one complete unique
selector can name the row that conflicted:
- it can — the skipped item links the existing target. The child insert is skipped and the distinct join row is still written, so the parent ends up connected to the row that was already there;
- it cannot — the target key is database-generated and the row spells two
independent uniques, or the conflict would fire on an index no
wherecan spell, or the only unique is compound with anullmember — and that member is suppressed instead: the target is not written, its join row is not written, every sibling row still lands, and the row that was already there is neither rewritten nor linked. viborm does not guess which existing row to adopt.
Later members observe targets and descendants earlier members created. This left-to-right visibility also applies when relation-bearing members require dynamic planning.
When a createMany member itself carries relations, it uses the record-series
contract, where a skipped member suppresses its complete subtree including the
join. Interactive drivers use a member savepoint. A batch-only driver isolates
the skippable root as one atomic segment, observes normalized row count, and does
not dispatch descendants after a skip. This is safe when no write or nested
series precedes that root. A member that needs such a prior effect refuses before
dispatch, because the effect would survive a later root skip.
Shapes:
| Relation | Shape |
|---|---|
To-one update |
update: { ...data } or update: { where?, data } — here where is a NON-unique filter the connected record must satisfy |
To-many update |
update: { where, data } (or an array of them) — here where is a unique selector |
To-one upsert |
upsert: { create, update } |
To-many upsert |
upsert: { where, create, update } (or an array) |
To-many updateMany |
updateMany: { where?, data } (or an array) |
To-many deleteMany |
deleteMany: where (or an array) |
Behavior:
- A nested
updateordeletewith a specific target throws if that record isn’t found (or belongs to another parent). - A to-one
update: { where, data }throws the same not-found error when the connected record fails the filter; the whole operation rolls back. The two spellings are told apart structurally — an object with adatakey whose value is an object is the envelope. On a related model owning a field nameddatathat shape means two different things (set the row’s fields, or store the document), so VibORM refuses it instead of picking one as Prisma does: spell the envelope out,update: { where: {}, data: { … } }. - Nested
updateManyanddeleteManymay match zero rows without throwing. - A nested
upsertcreates only when nothing matcheswhere; if the match belongs to another parent, it’s rejected rather than stolen or duplicated. - Required membership omits
disconnect. A to-manysetremains valid, but it must retain every current member. createManywithskipDuplicatesrejects a row containing only database-owned defaults before any parent or child write.
Structural restrictions
The matrices show operation support. These restrictions are independent:
- A required membership cannot be cleared while preserving the related record.
disconnectis absent;setrejects only when it would create a departing member. - To-one
disconnectanddeleteuse their boolean form. Selector arrays andsetare to-many operations. - To-one create payloads name at most one active operation. A to-one update
payload is read as one composition — an optional vacate, one supplier, an
optional
updateof the supplied row — on BOTH directions. The five spellable replacement pairs aredisconnect + connectOrCreate,disconnect + connect,disconnect + create,delete + connect, anddelete + create;deletebesideconnectOrCreateis excluded deliberately. Any supplier may also carry anupdate, with or without a vacate ahead of it, and the modify always applies to the supplied row after the supplier has run — so a relative update counts from the supplied value, and the update may itself carry relations, nested bulk, or another composition. On the child-held direction acreatesupplier, or aconnectOrCreatethat creates, is located after the fact by the membership it just established: that costs one extra round trip and needs a driver with either interactive transactions or native atomic batch, and a driver with neither declines before the supplier writes. On the parent-held directioncreateorconnectOrCreatebeside anupdateremains refused at the type boundary. Two further spellable compositions are declined before any write by the own-write analyzer, becausedelete: truenames a row whose identity is unknown at analysis and may be the one a sibling reads: parent-helddelete + connect, and child-helddeleteplus aconnectplus anupdate. The vacate always runs before the supplier, whatever order it is spelled in, and a parent-held vacate plus supplier folds to one foreign-key value with no transientNULL. Literalfalseis inactive. Other combinations fail at the validation and type boundary. Direct polymorphic payloads contain exactly one typed intent. To-many payloads may combine operation kinds. See Combining operations on one to-one relation. - Nested
createManyrows and nestedupdateMany.datamay contain ordinary or direct polymorphic relation writes. Scalar-only shapes stay grouped. A relation-bearing shape becomes an ordered record series at that exact position in the parent tree. Any no-transaction driver with native atomic batch executes nested relation-bearingcreateManyandupdateManywhen VibORM can verify the complete parent and, where needed, membership in every later write batch. A nested dynamic shape without that exact guard is refused before its containing member writes. If reached inside a progressive root bulk call, earlier root members can already be committed and are reported. - Relation-link selectors (
connect,disconnect,set, andconnectOrCreate.where) require unique discriminators; they do not accept the extra filters allowed by nested update and upsert targets. - Many-to-many relations support scalar and compound primary keys on either
side.
.source("owner")and.target("target")remain scalar tokens: for a compound side they expand toowner_1,owner_2, … andtarget_1,target_2, … in declared primary-key order. Reads, writes, cascades, and generated key publication consume the complete tuple. - A create-then-
connectOrCreateconflict on the same target in one payload is rejected as an OwnWrite conflict instead of depending on sibling visibility.
There is no runtime nested-write depth counter. Deep fresh and selected-record subtrees use the same record compilers at each level. TypeScript can still hit its own instantiation limit for an extremely deep literal; constructing and widening such a payload programmatically avoids that editor-only limit.
Database differences
The API is provider-agnostic. Remaining differences are limited to features whose underlying data model is not shared by every database:
Portable string mode: "insensitive" folds ASCII A-Z only on every
database. Non-ASCII code points remain exact, so behavior does not depend on a
database’s locale or ICU installation.
| Behavior | Difference |
|---|---|
| Transactions on serverless drivers | D1 Workers bindings and Neon HTTP have no interactive callback transaction. A default dynamic write can use several native atomic batches after exact guard preflight and normalized awaited success. A later failure preserves prior acknowledged segments; providers with supportsOrderedCommittedSegments report the exact callback-acknowledged prefix, while another batch provider can mark the last dispatched segment as possibly committed when its result cannot be decoded. Nested relation-bearing createMany/updateMany require the complete parent and, where needed, membership guard. A skippable root is isolated; only a write or nested series before it remains a pre-effect refusal. Explicit $transaction([...]) stays indivisible and refuses a dynamic series before member 0. See D1 and Transactions. |
| Database-generated keys a write must read back | Every driver publishes them on its interactive transaction path. On a batch-only substrate, SQLite, libSQL, D1 and MySQL use their exact statement-local insert-ID channels. PostgreSQL-family drivers, including Neon HTTP, use the producer’s own RETURNING: an exact fold stays one statement, and another default operation continues through guarded segments when later SQL needs the value. Those segments can commit a prefix. An explicit indivisible $transaction([...]) still refuses before effects when no exact one-batch lowering exists. See the row below. |
| More than one database-generated primary-key member | A database fact, not an ORM policy. PostgreSQL allows several generated columns in one key and viborm publishes every member exactly. MySQL allows one AUTO_INCREMENT column and SQLite/libSQL/D1 generate only the INTEGER PRIMARY KEY, so the compound shape cannot be declared there at all. viborm never derives one generated value from another. |
skipDuplicates on a relation-bearing nested write |
The row and everything under it are suppressed together. Interactive drivers use a savepoint. A batch-only driver isolates the root write, uses normalized row count to decide whether to dispatch descendants, and continues with siblings. It refuses only when a write or nested series must run before that root, because a skipped root could not roll that prior effect back. A many-to-many row with no conflictable unique still drops the vacuous flag, and a row with one exact selector still uses adopt-and-link. |
| Vector / geospatial | PostgreSQL with the relevant extension enabled; unsupported (and clearly errors) elsewhere. |
| Fixed decimals | s.decimal({ precision, scale }) is exact on every provider. PostgreSQL uses NUMERIC(p,s), MySQL uses DECIMAL(p,s), and SQLite stores a checked scaled integer coefficient. Results are fresh Decimal values. See the row below. |
JSON filter paths use one portable grammar. Segments containing " or \
reject before SQL generation on every database; they never degrade to an empty
result on one provider.
New MySQL 8 tables use the binary, no-pad utf8mb4_0900_bin collation so
string keys preserve case, accents, and trailing spaces like PostgreSQL and
SQLite. Existing tables require the explicit collation migration described in
the MySQL migration driver guide; upgrading the
package does not rewrite deployed tables.
Generated keys and the execution substrate
A key the database assigns is readable back by the same call that created the row, and the rows underneath it can use it:
// `id` is s.int().id().increment() — the child's FK gets the value the INSERT made
await client.org.create({
data: { name: "Acme", members: { create: [{ name: "Ada" }] } },
});
A primary key may have more than one generated member where the database
allows it, and upsert can create through it:
const twin = s
.model({ a: s.int().increment(), b: s.int().increment(), label: s.string() })
.id(["a", "b"]);
await client.twin.upsert({
where: { a_b: { a: 10, b: 20 } },
create: { label: "created" },
update: { label: "updated" },
});
On PostgreSQL the create arm returns both members from its own INSERT and
addresses the new row by the complete key. Every member is read exactly; none is
inferred from the position of another or from sequence adjacency. MySQL and
SQLite cannot declare this shape — one AUTO_INCREMENT column and one
INTEGER PRIMARY KEY respectively — so there is nothing to emulate there.
Where it can cost you something is the substrate, not the database:
| Driver | Interactive transaction | Batch-only path |
|---|---|---|
pg, postgres, pglite, bun-sql |
Supported: the key comes back on the INSERT’s RETURNING |
Supported: exact folds stay one statement; otherwise RETURNING values cross guarded atomic segments |
neon-http |
Not available — Neon HTTP has no callback transaction | Supported through guarded RETURNING segments; the operation is not atomic across segments |
mysql2, planetscale |
Supported | Supported — exact LAST_INSERT_ID() |
sqlite3, libsql, bun-sqlite |
Supported | Supported — exact last_insert_rowid() |
d1 |
Not available | Supported — exact last_insert_rowid() |
PostgreSQL never uses lastval(): it is session-global, so a trigger or another
generated column can move it. The producer’s own RETURNING is the exact source.
When later SQL needs that value and no one-statement fold exists, the default
operation awaits the producer batch, re-pins the created row and every consumed
non-key value, and then runs the dependent batch. A later failure can therefore
leave an earlier segment committed; the error carries progress and cache
invalidation follows each completed or possibly visible segment.
An explicit $transaction([operationA, operationB]) remains one indivisible native
batch. VibORM does not weaken that requested atomicity by splitting it, so an array
that needs a PostgreSQL RETURNING value across its internal statements still refuses
before effects unless the compiler can lower the complete array to one exact statement.
The same fail-closed boundary applies to a custom driver that offers neither callback
transactions nor atomic batches.
Fixed decimals
s.decimal({ precision, scale }) declares one exact domain. Storage, reads,
filters, ordering, cursors, grouping, aggregates, and atomic arithmetic have the
same logical answer on PostgreSQL, MySQL, and SQLite-family providers:
| Operation on a decimal | postgres | mysql | sqlite |
|---|---|---|---|
| Scalar storage | NUMERIC(p,s) |
DECIMAL(p,s) |
checked scaled INTEGER |
comparisons and orderBy |
exact | exact | exact coefficient comparison |
_min / _max / _sum / _avg |
exact Decimal |
exact Decimal |
exact coefficient aggregates |
increment / decrement / multiply / divide |
exact | exact | guarded integer arithmetic |
multiply, divide, and _avg use round-half-to-even when a derived result
needs quantization; input is never rounded to fit. Every update object takes
exactly one operation. _sum preserves scale and may widen beyond the field’s
storage precision.
SQLite raw SQL remains physical and sees the coefficient. Typed model queries
apply the descriptor and return a fresh Decimal. Full storage, list,
migration, provider-limit, and raw-SQL details are in the Decimal scalar
page.
Errors and Prisma error codes
VibORM throws typed error classes with a stable V#### code on error.code. Each class
declares the exact code it carries, so if (error.code === "V3001") narrows to
UniqueConstraintError with compiler support and a switch over a union of error types can
be checked for exhaustiveness — see the errors page for the full
taxonomy and the narrowing rules. For code written against Prisma, every error also carries
error.prismaCode — the Prisma code with the same meaning, or undefined when VibORM
claims none. Porting a handler is a one-token edit:
try {
await client.user.create({ data });
} catch (error) {
if (error.prismaCode === "P2002") {
// unique constraint — the same branch a Prisma app already has
}
}
| VibORM error | code |
prismaCode |
Prisma meaning |
|---|---|---|---|
UniqueConstraintError |
V3001 |
P2002 |
Unique constraint failed |
ForeignKeyError |
V3002 |
P2003 |
Foreign key constraint failed |
NotNullConstraintError |
V3003 |
P2011 |
Null constraint violation |
CheckConstraintError |
V3004 |
P2004 |
A constraint failed on the database |
ValueTooLongError |
V3005 |
P2000 |
Value too long for the column’s type |
NotFoundError |
V6001 |
P2025 |
Required records were not found |
ValidationError |
V4001 |
P2009 |
Failed to validate the query |
ConnectionError |
V1001 / V1002 / V1003 |
P1001 / P1002 / P1017 |
Can’t reach / timed out / server closed the connection |
ClientInitializationError |
V1004 |
P1012 |
Client could not be constructed from the given schema and configuration |
The table is deliberately partial. Transaction failures, nested-write failures, cache,
migration and pending-operation errors report prismaCode: undefined rather than a
near-neighbour code — an unclaimed code is honest, a wrong one silently mis-routes your
catch. For retry decisions use error.isRetryable(), which covers deadlocks,
serialization failures, and timeouts on every database.
error.toJSON() includes prismaCode when one is claimed and omits the key entirely
otherwise.
ValueTooLongError is PostgreSQL and MySQL only. SQLite does not enforce declared
column lengths — an over-long value is stored, not rejected — so no P2000 can be raised
there. Prisma behaves the same way.
Type and API differences
| Area | VibORM |
|---|---|
| Type generation | Types are inferred from your schema — there’s no prisma generate step. |
| Prisma helper types | VibORM doesn’t expose XOR, Exact, SelectSubset, or generated model helper types. |
| Create/update inputs | A single blended input surface rather than Prisma’s separate checked/unchecked variants. |
| Bulk writes that return rows | Implicit: createMany / updateMany return rows when the call carries a select or query-level omit, and { count } otherwise. Prisma’s createManyAndReturn / updateManyAndReturn methods do not exist — migrate createManyAndReturn(args) to createMany({ ...args, select }), same for updateManyAndReturn. deleteMany accepts the same projections, which Prisma has no form of. On all three the projection is scalar-only and include is rejected: VibORM does not project relations into a bulk write’s returned rows, and refuses rather than answer wrongly. Prisma does accept relations on createManyAndReturn/updateManyAndReturn; read them in a separate query instead. |
omit |
Query-level omit and the official defaultOmit() extension support Prisma’s local { field: false } override and refuse an omit that empties the result. Three differences: VibORM lets query-level omit subtract from select (Prisma rejects the pair); VibORM has a schema-level .omit() that is a hard exclusion no client or query can undo; and omit on a bulk write returns rows, following the implicit-returning rule above. See Omitting fields. |
| Raw SQL | Prisma’s shape: tagged $queryRaw/$executeRaw plus $queryRawUnsafe/$executeRawUnsafe. $queryRaw answers T[], $executeRaw answers the affected count. $queryRaw/$executeRaw still accept the pre-1.0 (sql: string, params?) form for one release, with a deprecation notice on the warning log channel. Raw calls are lazy, promise-compatible operations and can mix with model operations in $transaction([...]). See Raw SQL. |