Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

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 where can spell, or the only unique is compound with a null member — 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 update or delete with 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 a data key whose value is an object is the envelope. On a related model owning a field named data that 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 updateMany and deleteMany may match zero rows without throwing.
  • A nested upsert creates only when nothing matches where; if the match belongs to another parent, it’s rejected rather than stolen or duplicated.
  • Required membership omits disconnect. A to-many set remains valid, but it must retain every current member.
  • createMany with skipDuplicates rejects 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. disconnect is absent; set rejects only when it would create a departing member.
  • To-one disconnect and delete use their boolean form. Selector arrays and set are 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 update of the supplied row — on BOTH directions. The five spellable replacement pairs are disconnect + connectOrCreate, disconnect + connect, disconnect + create, delete + connect, and delete + create; delete beside connectOrCreate is excluded deliberately. Any supplier may also carry an update, 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 a create supplier, or a connectOrCreate that 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 direction create or connectOrCreate beside an update remains refused at the type boundary. Two further spellable compositions are declined before any write by the own-write analyzer, because delete: true names a row whose identity is unknown at analysis and may be the one a sibling reads: parent-held delete + connect, and child-held delete plus a connect plus an update. 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 transient NULL. Literal false is 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 createMany rows and nested updateMany.data may 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-bearing createMany and updateMany when 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, and connectOrCreate.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 to owner_1, owner_2, … and target_1, target_2, … in declared primary-key order. Reads, writes, cascades, and generated key publication consume the complete tuple.
  • A create-then-connectOrCreate conflict 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.

Was this page helpful?