@vibeorm/dsl
v5.0.4
Published
TypeScript schema DSL for VibeORM v2 — compiles to canonical SchemaIR, with .prisma generation for interop
Maintainers
Readme
@vibeorm/dsl
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 code-first schema input: models and relations written in TypeScript, compiled straight to the canonical SchemaIR. It is the peer of the .prisma parser, and it also emits .prisma text for interop.
bun add @vibeorm/dslStable.
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. Reach for it when you are building tooling on the IR or SQL layer.
Usage
// src/schema.ts
import { dsl } from "@vibeorm/dsl";
const Role = dsl.enum({ name: "Role", options: ["USER", "ADMIN"] });
const User = dsl.create({
name: "User",
schema: {
id: dsl.id({ type: "autoincrement" }),
createdAt: dsl.datetime().defaultNow(),
updatedAt: dsl.datetime().updatedAt(),
email: dsl.string().unique(),
name: dsl.string().optional(),
role: Role.field({ default: "USER" }),
},
});
const Post = dsl.create({
name: "Post",
schema: {
id: dsl.id({ type: "uuid" }),
title: dsl.string(),
published: dsl.boolean().default(false),
meta: dsl.json().optional(),
authorId: dsl.int().index(),
},
indexes: (i) => [i.unique("authorId", "title")],
});
const relations = dsl.defineRelations({ User, Post }, (r) => ({
User: { posts: r.many.Post({ from: r.User.id, to: r.Post.authorId }) },
Post: { author: r.one.User({ from: r.Post.authorId, to: r.User.id, onDelete: "Cascade" }) },
}));
export const schema = dsl.schema({ Role, User, Post }, { relations });
export default schema;Point vibeorm.config.ts at the .ts entry (schema: "./src/schema.ts") and the CLI takes it from there. The module must export the composed schema as default, as export const schema, or as a schema property on the default export.
To go to the IR yourself: dsl.toIR({ schema }) returns a SchemaIR, optionally taking provider and an extensions registry. dsl.validateSchema(schema) returns ValidationError[] without throwing.
Column factories
dsl.string() · dsl.int() · dsl.bigint() · dsl.float() · dsl.decimal() · dsl.boolean() · dsl.datetime() · dsl.json() · dsl.bytes() · dsl.id({ type }) · dsl.unsupported("tsvector") · dsl.extensionType({ ... }) · dsl.enum(...).
dsl.enum has two forms: with a name it creates a reusable enum whose .field() yields a column, without one it creates an inline single-use enum column.
Id strategies are dsl.id({ type: "ulid" | "uuid" | "cuid" | "nanoid" | "autoincrement" | "none" }). The default is "ulid", a String column generated in the app; "autoincrement" makes it an Int. A key the database mints takes type: "none" with a database default: dsl.id({ type: "none" }).uuid().dbgenerated("uuidv7()") is Prisma's String @id @db.Uuid @default(dbgenerated("uuidv7()")).
Chainable modifiers
On every column: .optional(), .unique(), .index(), .array(), .default(value), .map("db_col"), .dbgenerated("expr"), .comment("...").
Kind-specific: .maxLength(n), .text() and .uuid() on strings (.uuid() also on ids), .precision(p, s) on decimals, .defaultNow(), .updatedAt() and .tz() on datetimes. .tz({ precision: 3 }) declares @db.Timestamptz(3): it stores exactly what a JavaScript Date holds, so a database-written time reads back unchanged and findPage can order by the column without an assertion. .uuid() declares @db.Uuid: PostgreSQL's native uuid type (the value stays a string); SQLite stores it as TEXT; MySQL has no uuid type and refuses the schema with VIBE_UNSUPPORTED_CAPABILITY.
Database defaults
arrival: dsl.bigint().autoincrement(),
settings: dsl.json().default({ notifications: true, channels: [] }),
tags: dsl.string().array().default([]),dsl.int() also supports .autoincrement(). The config form is { autoincrement: true }. Neither form implies a primary key or uniqueness. PostgreSQL/PGlite allow non-primary counters; SQLite requires the single integer primary key; MySQL requires a leading index and permits only one counter per table. Existing serial columns compare equal to the declaration without replacing their sequences. Adding auto-increment to an existing ordinary column remains an explicit migration operation, not a sequence reset.
JSON objects, arrays, booleans, numbers and JSON null become database defaults. String arguments retain the Prisma-compatible serialized-JSON convention, for example .default('"hello"'). Cycles, undefined members, non-finite numbers and class instances are refused. Scalar-list defaults are supported on PostgreSQL/PGlite, including empty lists and exact BigInt elements. Other dialects cannot store scalar-list columns. Defaults are database-owned, so inserts may omit these fields. A literal default cannot be combined with .dbgenerated() or .autoincrement().
Physical index names use map, separately from a compound unique's client-selector name:
indexes: (i) => [i.on(["arrival"], { map: "arrival_lookup" })],On PostgreSQL and PGlite a non-unique index can set an operator class per field with ops, keyed by field name, each value a qualified name:
indexes: (i) => [i.on(["email"], { ops: { email: { namespace: "pg_catalog", name: "text_pattern_ops" } } })],ops on i.unique(...) is refused (VIBE_UNSUPPORTED_CAPABILITY), as are operator classes on SQLite and MySQL. dsl.generate.prisma refuses a schema that uses them, because Prisma's ops: syntax cannot carry the qualified name.
Relations
Relations are declared separately from columns, in dsl.defineRelations(models, (r) => ...), which keeps column references typed on both ends.
- One side:
r.one.X({ from, to, optional?, alias?, ownsForeignKey?, onDelete?, onUpdate? }). - Many side:
r.many.X({ from, to }). - Implicit many-to-many: both sides
r.many, between two DIFFERENT models. A many-to-many from a model to itself is refused (VIBE_SCHEMA_INVALID, ruleimplicit-self-many-to-many) because both sides would own the same join-table column; use an explicit junction model with one foreign key per direction. - Explicit many-to-many, through a junction model:
r.many.Group({ from: r.User.id.through(r.UserGroup.userId), to: r.Group.id.through(r.UserGroup.groupId) }). - Self-relations, and several relations between the same pair of models, disambiguate with
alias.
For a shared-primary-key one-to-one, set ownsForeignKey: true on the side whose table carries the foreign key. Ambiguous pairs now fail instead of deriving ownership from declaration order. ownsForeignKey: false marks the inverse side; conflicting declarations fail. Conversion emits ownership explicitly.
Large schemas can split relations across files with dsl.defineRelationsPart and recombine them with dsl.mergeRelations; dsl.domain({ name, models }) groups models.
Templates
dsl.templates.base spreads id, createdAt, updatedAt and deletedAt into a model. Also available: baseWithWorkspace, withAudit, withActive, withMetadata, withNameDescription, withExternalId, and fields.
const Workspace = dsl.create({
name: "Workspace",
schema: { ...dsl.templates.base, name: dsl.string() },
});Interop
dsl.generate.prisma(schema) renders the schema as .prisma text, and dsl.generate(schema) returns it as a GeneratorOutput. That is what backs the CLI's conversion in both directions:
vibeorm convert --to dsl --out src/schema.ts # .prisma → TS DSL
vibeorm convert --to prisma --out prisma/schema.prisma # TS DSL → .prismaConversion goes through the IR and round-trips are tested: emitted DSL compiles back to the same IR. convert --to dsl refuses constructs the DSL cannot express — views, composite-column foreign keys, non-standard index kinds, enum @@map names, exotic @db.* types — with one VIBE_CONFIG error naming every offender.
