@vibeorm/runtime
v5.7.0
Published
Driver-agnostic query engine and client runtime for VibeORM v2
Maintainers
Readme
@vibeorm/runtime
Part of VibeORM — a type-safe TypeScript ORM for Bun and Node. Prisma-schema or TypeScript-DSL input, a canonical schema IR, a generated client, and a dialect-aware SQL layer over PostgreSQL, PGlite, SQLite and MySQL.
The query engine behind the generated client: it turns delegate calls into SQL statements, loads relations, runs nested writes and moves values across the wire through the codec table. It is its own package because it is driver-agnostic — it talks to databases only through the DatabaseAdapter contract, so adapters and the generator can depend on it without depending on each other.
bun add @vibeorm/runtimeStable.
2.0.0, published under thelatestdist-tag. The v1 line stays installable asvibeorm@1.
Most users never install this directly — it ships as a dependency of vibeorm and of the generated client. Reach for it when you are writing an adapter or tooling against the runtime.
What it does
createClient({ schema, adapter, options }) builds a DynamicClient from the Schema IR at runtime: model delegates keyed by both camelCase client name and model name ($models), plus $transaction, $queryRaw, $executeRaw, $queryRawUnsafe, $executeRawUnsafe, $on, $connect and $disconnect. The delegates are deliberately broad here (ClientRow in, ClientRow out); the generated .d.ts declares the precise per-model types on top.
Each ModelDelegate implements 16 methods:
| Group | Methods |
|---|---|
| Read | findMany · findFirst · findFirstOrThrow · findUnique · findUniqueOrThrow |
| Create | create · createMany · createManyAndReturn |
| Update | update · updateMany · upsert |
| Delete | delete · deleteMany |
| Aggregate | count · aggregate · groupBy |
The statement-building pieces are exported too, for tooling that needs SQL without executing it: buildQuery, compileWhere, compileSelect, compileOrderBy, compileProjection, resolveResultShape, and the runtime metadata builders buildRuntimeMeta, getModelMeta, getFieldMeta.
Model metadata
describeSchema({ schema, computed? }) is the supported server-side metadata contract. Pass the schema exported by the generated client, and the same computed-field config passed to VibeClient if you need computed descriptions:
import { describeSchema } from "@vibeorm/runtime";
import { schema } from "./generated/vibeorm/index.js";
const description = describeSchema({ schema });
const order = description.models.find((model) => model.clientName === "order");
const lines = order?.relations.find((relation) => relation.name === "lines");
// lines?.foreignKey === "target"
// lines?.keys === [{ field: "id", targetField: "orderId" }]The result contains model/delegate names, physical table and column names, namespaces, view flags, ordered primary keys, queryable scalar/enum fields, enum values, computed type text/dependencies and resolved relation wiring. Relation foreignKey is "local", "target" or "joinTable"; keys preserves composite-key order, and inverse names the paired relation or is null. Implicit joins include their physical table and oriented A/B columns. Invalid or unresolvable metadata fails with the shared runtime builders' typed VibeError; it does not return an unsupported placeholder.
Every returned object and array is a detached, deeply frozen copy. Unsupported columns, datasource URLs, SQL defaults, policies/RLS details, validator bindings and compute callbacks are omitted. Computed callbacks are never run. Keep this metadata on the server; do not send it to browsers. Physical schema names are not a public application contract. Minor versions may add description properties; removing or renaming them requires a major version. RuntimeMeta, ModelMeta, FieldMeta and their builders/resolvers remain internal tooling exports without this shape-stability promise.
Consumer-owned Zod output schemas
Zod is supported through the existing Standard Schema write-validation and typed-JSON seams. Frameworks can also define public read schemas in Zod and use .pick to validate exactly the selected values:
import { z } from "zod";
const PublicOrder = z.object({
id: z.number().int(),
reference: z.string(),
createdAt: z.date(),
total: z.string(), // Decimal values are decoded strings.
});
const SelectedOrder = PublicOrder.pick({ reference: true, createdAt: true }).strict();
const row = await db.order.findFirstOrThrow({
select: { reference: true, createdAt: true },
});
const validated = SelectedOrder.parse(row);The framework owns this schema and its selected public shape. These validators check decoded in-process values: DateTime is a Date, BigInt is a bigint, Bytes is a Uint8Array, and Decimal is a string. Wire encoding belongs to the framework. Computed type text alone cannot validate a computed value; provide its public validator explicitly. No per-model Zod schemas or other validation output are added to the generated client.
The adapter contract
DatabaseAdapter is frozen. Implement it and any driver works with the whole ORM:
import type { DatabaseAdapter, QueryResult, TransactionOptions } from "@vibeorm/runtime";
const adapter: DatabaseAdapter = {
dialect: "postgres",
provider: "postgresql",
async execute(params: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> { /* … */ },
async executeUnsafe(params: { text: string; values?: unknown[] }): Promise<QueryResult> { /* … */ },
async transaction<T>(fn: (tx: DatabaseAdapter) => Promise<T>, options?: TransactionOptions): Promise<T> { /* … */ },
async connect(): Promise<void> { /* … */ },
async disconnect(): Promise<void> { /* … */ },
formatArrayParam(values: unknown[]): unknown { /* … */ },
};executeis the ORM hot path (row-returning, statement-cached by the adapter).executeUnsafeis the raw/DDL path and is the only placeaffectedRowssurfaces.transactionnests: an inner call creates a savepoint.isolationLevelandtimeoutare top-level only — a nested call must refuse both withVIBE_VALIDATION.- Adapters never let raw driver errors escape. Each maps its driver's failures to
VibeErrorcodes (VIBE_UNIQUE_VIOLATION,VIBE_FK_VIOLATION,VIBE_TRANSACTION,VIBE_ADAPTER) through a per-driver table, with the driver error ascause. formatArrayParamconverts a JS array into the driver's preferred scalar-array representation; the ORM path binds arrays through it, the raw path does not.- The optional
wiredeclaration (WireFidelity) tells the runtime what the driver already delivers for non-list scalars, so provably no-op decoders can be skipped. - The optional
rowChanges()member reports the surviving direct row changes of the adapter's active transaction as a number or"unknown"; it backstransactionRowChanges. An adapter without it reports"unknown". The kit'sstatementRowChanges,observeRowChangesandreadRowChangesimplement it for the shipped adapters. - The optional
transactionOutcome: "reported"declaration says that a failed top-leveltransaction()records what happened beside the value it throws (commit outcome, whetherCOMMITwas sent, the cleanupROLLBACK, the connection). An adapter reports through the kit'ssettleFailedTransaction, which applies the one commit-outcome rule; undeclared means no reports.
toSqlExecutor({ adapter }) bridges an adapter to the SqlExecutor shape @vibeorm/migrate runs on.
@vibeorm/runtime/adapter-kit carries the transaction helpers every shipped adapter is built on, so a custom adapter does not re-implement them: savepoint naming (createSavepointCounter), the savepoint lifetime gate that serialises overlapping sibling transaction() calls on one connection (createSavepointScope, runRootScope, runNestedSavepoint, scopeTurn), and the transaction budget behind TransactionOptions.deadline (validateTransactionDeadline, startTransactionBudget, engineStatementBudgetMs and the typed refusals). The shipped adapters re-export them unchanged.
Helpers for frameworks
db.$querySqlResult({ query, decoder? })executes a constructed SQL fragment once and returnsSqlQueryResult<T>with bothrowsand the adapter's affectedrowCount. Rows stay driver-native without a decoder. An explicitResultReadDecoder<T>needs onlydecode; no encoder is required for result conversion. The method retains existing raw transaction/context handling and is refused by tenant-scoped and read-only clients (queries.md).transactionOutcomeOf({ error })returns the facts of the failed top-level transaction that threwerror(commit:"not-committed"or"unknown",commitSent,rollback,connection), orundefinedwhen there are none. The error itself is unchanged; branch onadapter.transactionOutcome === "reported", never on an adapter name (queries.md).transactionRowChanges({ db })returns the direct row changes that survive in the transaction behind a handle, or"unknown"when some statement could not be observed (queries.md).createDelegateMethodClassifier({ db })returns({ method, model? }) => "read" | "write" | undefinedfor that handle's core and configured extension methods;undefinedmeans denied (queries.md).advisoryKeyOf({ text })maps a string to a signed 64-bitbigintadvisory-lock key (SHA-256 of the UTF-8 bytes, first eight bytes, big-endian). Different strings can map to the same key (queries.md).advisoryKeySql({ text })is the same key computed by PostgreSQL: it wraps asqlfragment that yields text and returns abigintfragment, so a statement can hash values it selects and lock them (PostgreSQL only).
Relation strategies
const db = createClient({ schema, adapter, options: { relationStrategy: "join" } });"query": one query per relation, batched withWHERE ... IN. Works on every dialect; the default on SQLite."join": a single statement usingLEFT JOIN LATERALwith JSON aggregation. The default on PostgreSQL and PGlite (since 5.0) and on MySQL 8.0.14+ (MySQL keeps"query"until the adapter knows its server runs LATERAL, and when the adapter does not declare a UTC DATETIME reading); an explicit"join"on SQLite, MariaDB or MySQL before 8.0.14 throwsVibeErrorwith codeVIBE_UNSUPPORTED_CAPABILITY.
A relationStrategy argument on a find overrides the client default. Relation trees are resolved by resolveRelationSelection / resolveRelationLink and attached by attachRelations (query strategy) or attachLateralRelations (join strategy).
Codecs
Value encoding and decoding lives in one table per dialect: POSTGRES_CODECS, SQLITE_CODECS, MYSQL_CODECS, indexed by DIALECT_CODECS and reachable through getCodec({ dialect, type }). Nothing else in the codebase coerces values — scattered coercion is what produced v1's BigInt, enum-array and timestamp bugs.
import { decodeRows, encodeValue, getCodec } from "@vibeorm/runtime";encodeValue runs on every parameter, decodeValue / decodeRow / decodeRows on every row materialization point — including relation children and join-strategy rows, not only top-level parents. decodeAggregateValue handles aggregate columns, whose result type differs from the underlying field's.
Links
- Documentation
- Query API
- Repository and issues
- MIT licensed
