Semola

ORM

Typed tables and queries on Bun SQL (SQLite or Postgres)

Define tables once, get typed create / find / update / delete helpers. Uses Bun's SQL client under the hood.

import {
  createOrm,
  defineTable,
  string,
  uuid,
  number,
  boolean,
  one,
  many,
} from "semola/orm";

Define tables and open a client

const users = defineTable("users", {
  id: uuid("id").primaryKey().notNull(),
  name: string("name").notNull(),
  email: string("email").notNull().unique(),
});

const posts = defineTable("posts", {
  id: uuid("id").primaryKey().notNull(),
  title: string("title").notNull(),
  authorId: uuid("authorId")
    .notNull()
    .references(() => users.columns.id),
});

const db = createOrm({
  adapter: "sqlite", // or "postgres"
  url: ":memory:",
  tables: { users, posts },
  relations: {
    users: {
      posts: many(() => posts),
    },
    posts: {
      author: one("authorId", () => users),
    },
  },
});

Column builders: string, number, boolean, uuid, date, json, jsonb, enumType. Chain .primaryKey(), .notNull(), .nullable(), .unique(), .default(fn), .references(() => other.columns.col).

one(foreignKeyColumn, () => table) uses the source table's FK column name. many(() => table) is the reverse side.

You still create the physical schema yourself (migrations / $raw). The ORM does not auto-migrate.

Queries

await db.users.create({
  data: {
    id: "u1",
    name: "Ada",
    email: "ada@example.com",
  },
});

const user = await db.users.findFirst({
  where: { email: "ada@example.com" },
});

const page = await db.posts.findMany({
  where: { authorId: "u1" },
  orderBy: { title: "asc" },
  take: 20,
  skip: 0,
  include: { author: true },
});

await db.users.update({
  where: { id: "u1" },
  data: { name: "Augusta" },
});

await db.posts.deleteMany({
  where: { authorId: "u1" },
});

where supports column operators and $and / $or / $not. Relation filters use every / some / none. Pass select to shape the returned fields. $skipHooks bypasses hooks for that call.

Transactions and raw SQL

await db.$transaction(async (tx) => {
  await tx.users.create({ data: { /* ... */ } });
  await tx.posts.create({ data: { /* ... */ } });
});

await db.$raw.unsafe(`CREATE TABLE IF NOT EXISTS users (...)`);

$raw is the underlying Bun.SQL instance. Inside a transaction, tx exposes the same table clients plus $raw.

Hooks

Hooks receive a context and may return patched options:

const db = createOrm({
  adapter: "sqlite",
  url: ":memory:",
  tables: { users },
  hooks: {
    beforeCreate(ctx) {
      return {
        data: {
          ...ctx.options.data,
          name: ctx.options.data.name.trim(),
        },
      };
    },
    tables: {
      users: {
        afterCreate(ctx) {
          console.log("created", ctx.result.id);
        },
      },
    },
  },
});

On this page