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 select — Prisma’s separate createManyAndReturn does not exist. That select is scalar-only (no relation key, no _count, no include). Relation nesting isn’t supported inside createMany. |
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 select — Prisma’s separate updateManyAndReturn does not exist. That select is scalar-only. Prisma 6’s limit is supported, with Prisma’s own 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. _min/_max/_sum/_avg over a s.decimal() are refused on SQLite — see Decimals on SQLite. |
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 update / upsert / delete targets take the extended where too — a
superset of Prisma, which is unique-only in those three positions.
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 |
|---|---|---|
To-one (oneToOne, manyToOne) |
is, isNot |
Filters against the related model’s where. Pass null to match records with no relation. |
To-many (oneToMany, manyToMany) |
some, every, none |
every also matches records with no related rows; combine with some: {} when you need at least one. |
Nested writes
Nested writes are supported inside create, update, and both branches of upsert. What you can nest depends on the context — you can’t update or delete a related record inside a create, because there’s nothing to update yet.
| In a… | create | createMany | connect | connectOrCreate | disconnect | delete | set | update | updateMany | upsert | deleteMany |
|---|---|---|---|---|---|---|---|---|---|---|---|
create (or upsert create branch) |
✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | — |
update (or upsert update branch) |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
createMany, updateMany, and deleteMany apply to to-many relations only. For a to-one relation use create, update, and delete: true.
On a many-to-many relation, createMany inserts each row and links it to the parent. skipDuplicates there skips the child ROW’s insert — the join row is a different row and is still written, so a skipped item leaves the existing target untouched and links it. It is refused (typed, before any write) when the target’s primary key is database-generated: a skipped insert produces no identity for its join row. Supply the key, or drop skipDuplicates.
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. - You can’t
disconnect, empty-set, or otherwise orphan records on a required relation. createManywithskipDuplicatesrejects a row containing only database-owned defaults before any parent or child write.
Planned superset — nested upsert under create: Prisma has no upsert in a create input, and the current engine rejects it. VibORM’s input surface accepts it as a deliberate superset with global-lookup, adopt-and-update semantics — the target is located by its own unique where, an existing row is adopted (reparented) and updated, and a missing row is created under the new parent. This completes the adopt family that connect/connectOrCreate already provide under create. It becomes executable only when the create tree routes to the query-engine-v2 path; until then it still rejects, so there is no mid-migration behavior change.
No engine depth limit — deep write subtrees at any depth (the depth lift, finished in X1b): a nested create under a located target (inside an update, or inside an upsert update branch) is a create SUBTREE that folds recursively to any depth, and a relation-carrying fresh child now runs the full create-root machinery at every level — for example post.update({ where, data: { author: { update: { posts: { create: { title, comments: { create: { body, … } } } } } } } }). Every fresh row becomes the parent of its own nested writes, and every mechanism the top-level create supports is available at any depth: a database-generated or compound primary key on a fresh child (its produced id threads to its own grandchildren), a parent-held to-one written before its fresh holder, the fresh-parent adopt family (connect / connectOrCreate / upsert), a createMany with skipDuplicates, and many-to-many through the junction. These execute natively, byte-identically on transaction and batch substrates. Semantic rules are unchanged at every depth: a create-then-connectOrCreate on the same key still rejects with “Split these operations into separate queries”, and validation still rejects unknown keys, wherever they appear in the tree.
The only depth ceiling is the TypeScript compiler’s, not the engine’s. Inferring a deeply-nested literal payload is a type-instantiation cost: a rich per-level literal (a children.create plus a parent-held create plus an M2M create at each level) type-checks to ~31 levels and then raises TS2321 “Excessive stack depth comparing types”. That is a DX limit on the client input inference, far past any hand-written payload; the runtime carries no depth counter and folds deeper. If you ever build a payload past that ceiling, construct it programmatically (a plain object, widened to the input type) so the compiler never infers the deep literal — the engine executes it unchanged (a 40-level chain is exercised in the test estate). One shape is still one level short at a located target and routes as a typed refusal (a distinct upcoming mechanism, the located-update projection of child-SET folding — not a depth limit): a deeper parent-held to-one, or a non-primary-key / compound reference, of an existing row being updated.
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 support batch mode only. Cloudflare D1 support uses the binding because its batch() primitive provides the required atomic boundary. See Transactions. |
| Vector / geospatial | PostgreSQL with the relevant extension enabled; unsupported (and clearly errors) elsewhere. |
Ordering, aggregating and arithmetic on a s.decimal() |
SQLite refuses them; PostgreSQL and MySQL answer them exactly. SQLite has no exact decimal type, so viborm stores decimals in a TEXT column there and raises UnsupportedOperationError (V8003) rather than routing the answer through a double. 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.
Decimals on SQLite
Storage, reads, and equality filters (equals, not, in, notIn) are exact
on every database, as are count, distinct, groupBy on the column, and
set. What SQLite cannot do exactly, it refuses:
| Operation on a decimal | postgres | mysql | sqlite |
|---|---|---|---|
lt / lte / gt / gte, orderBy |
exact | exact | UnsupportedOperationError |
_min / _max / _sum / _avg (including groupBy and having) |
exact | exact | UnsupportedOperationError |
increment / decrement / multiply / divide |
exact | exact | UnsupportedOperationError |
The orderBy refusal covers every spelling of an ordering, not just the
bare one: take / skip / cursor windows, findFirst, ordering through a
to-one relation, ordering inside a nested read, and groupBy’s orderBy.
_count is never refused — counting rows needs no ordering.
A gt that silently skips a row differing in the 20th digit is worse than an
error, so it is an error, and the error names the field, the reason, and the two
workarounds (s.float() for approximate ordering, scaled integers in an
s.bigInt() for exact ordered money). Full detail, including the column-type
migration, is 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, and { count } otherwise. Prisma’s createManyAndReturn / updateManyAndReturn methods do not exist — migrate createManyAndReturn(args) to createMany({ ...args, select }), same for updateManyAndReturn. deleteMany takes select too, which Prisma has no form of. On all three the select 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 and client-level omit match Prisma, including select/omit exclusivity, the local { field: false } override of a client default, and the refusal of an omit that empties the result. Two differences: viborm also has a SCHEMA-level .omit() (Prisma has none) which is a HARD exclusion no client or query can undo — the field has neither a select nor an omit key; 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. $transaction([...]) takes model operations only — run raw SQL inside the interactive form. See Raw SQL. |