Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

Model

Define database tables with fields: scalar columns and relation associations

Creating a Model

Use s.model() with an object of field definitions (scalars and relations):

import { s } from "viborm";

const user = s.model({
  id: s.string().id().ulid(),
  email: s.string().unique(),
  name: s.string(),
  createdAt: s.dateTime().default(() => new Date()),
});

Table Name

By default, VibORM uses the variable name. Use .map() to set a custom table name:

const user = s
  .model({
    id: s.string().id(),
    // ...
  })
  .map("users"); // SQL: CREATE TABLE "users" (...)

Chainable Methods

.map(tableName)

Sets the database table name.

.map("users")
.map("user_accounts")

.index(fields, options?)

Adds an index on one or more fields. Fields are always passed as an array:

// Single field index
.index(["email"])

// Composite index
.index(["lastName", "firstName"])

// With options
.index(["status", "createdAt"], {
  name: "idx_status_date",
  type: "btree",        // see the table below
  unique: false,
  where: "status != 'deleted'"  // Partial index (PostgreSQL, SQLite)
})

Not every type exists on every database. push and migrate refuse an index whose type the target does not have, naming the index and listing what that dialect supports — they never quietly build a different index:

type PostgreSQL MySQL SQLite
btree (default)
hash ❌ (InnoDB has no user-defined HASH index)
gin, gist
fulltext, spatial ✅ (cannot be combined with unique)

where is the same story: PostgreSQL and SQLite build the partial index, and MySQL — which has no partial index at all — refuses the declaration by name rather than indexing the rows your schema excluded.

The foreign-key fields of a manyToOne relation are indexed for you: push and migrate add an index over them on every database. Declare your own index over the same fields — first in the list, so the index still serves them — when you want more columns in it; the automatic index is then not added.

Composite indexes for ordered includes

An include that both orders and limits a to-many relation is the case worth widening that index for. Each parent’s children are read by a subquery that filters on the foreign key, sorts, and takes the first N:

await client.user.findMany({
  include: {
    posts: { orderBy: { createdAt: "desc" }, take: 5 },
  },
});

With an index on the foreign key alone the database can find a user’s posts quickly but must then read all of them and sort before it can take five. An index over the foreign key and the order column, in that order, is already sorted within each parent, so the database walks it and stops at five:

const post = s.model({
  id: s.string().id().ulid(),
  title: s.string(),
  createdAt: s.dateTime().now(),
  authorId: s.string(),
  author: s.manyToOne(() => user).fields("authorId").references("id"),
}).index(["authorId", "createdAt"]);

The foreign key comes first because it is the equality; the order column comes second because it is the sort. Reversing them serves neither. Declaring it also replaces the automatic foreign-key index rather than adding a second one, since its leading column is the same.

A relation you include without orderBy, or without take/skip, does not need this — the automatic foreign-key index already answers it.

.id(fields, options?)

Defines a compound primary key (when multiple fields form the PK):

const membership = s.model({
  orgId: s.string(),
  userId: s.string(),
  role: s.string(),
}).id(["orgId", "userId"]);

// With custom constraint name
.id(["orgId", "userId"], { name: "membership_pk" })

.unique(fields, options?)

Adds a compound unique constraint:

const user = s.model({
  id: s.string().id(),
  email: s.string(),
  orgId: s.string(),
}).unique(["email", "orgId"]);

// With custom constraint name
.unique(["email", "orgId"], { name: "user_email_org_unique" })

.extends(fields)

Adds additional fields to an existing model:

const baseModel = s.model({
  id: s.string().id(),
  createdAt: s.dateTime().default(() => new Date()),
});

const user = baseModel.extends({
  email: s.string().unique(),
  name: s.string(),
});

Index Options

Option Type Description
name string Custom index name
type "btree" | "hash" | "gin" | "gist" Index type (PostgreSQL)
unique boolean Unique index
where string Partial index condition

Complete Example

const user = s
  .model({
    // Primary key with auto-generation
    id: s.string().id().ulid(),

    // Required fields
    email: s.string().unique(),
    passwordHash: s.string(),
    orgId: s.string(),

    // Optional fields
    name: s.string().nullable(),
    bio: s.string().nullable(),

    // With defaults
    role: s.enum(["USER", "ADMIN"]).default("USER"),
    active: s.boolean().default(true),
    createdAt: s.dateTime().default(() => new Date()),
    updatedAt: s.dateTime().updatedAt(),

    // Relations
    posts: s.oneToMany(() => post),
    profile: s.oneToOne(() => profile).optional(),
  })
  .index(["email"])
  .index(["role", "active"])
  .unique(["email", "orgId"]);

Was this page helpful?