Database (Drizzle)
WebJs uses Drizzle as the default ORM. It fits the buildless thesis: there is no codegen and no engine binary (what you write is what runs), it runs on Node and Bun, and the types are inferred straight from your schema. SQLite is the default; Postgres is a flag away. The scaffold wires it all up under a db/ folder.
What the scaffold gives you
db/ columns.server.ts column helpers (the few bits that differ per dialect) schema.server.ts your models + relations connection.server.ts opens the driver, exports `db` seed.server.ts optional seed (run via `webjs db seed`) migrations/ generated SQL (committed) drizzle.config.ts drizzle-kit config (root)
Only db/columns.server.ts and db/connection.server.ts are dialect-specific. schema.server.ts, your queries, and your actions are identical whether you run SQLite or Postgres.
The schema
Write models against the helpers in db/columns.server.ts (which re-export the raw Drizzle builders like text / integer plus thin helpers for the columns that differ across dialects: table, pk, uuidPk, bool, createdAt, updatedAt, index):
// db/schema.server.ts
import { defineRelations } from 'drizzle-orm';
import { table, pk, text, integer, createdAt, index } from './columns.server.ts';
export const users = table('users', {
id: pk(),
email: text().notNull().unique(),
name: text(),
createdAt: createdAt(),
});
export const posts = table('posts', {
id: pk(),
slug: text().notNull().unique(),
title: text().notNull(),
body: text().notNull(),
authorId: integer().notNull().references(() => users.id, { onDelete: 'cascade' }),
createdAt: createdAt(),
}, (t) => [index(t.authorId), index(t.createdAt)]);
export const relations = defineRelations({ users, posts }, (r) => ({
users: { posts: r.many.posts({ from: r.users.id, to: r.posts.authorId }) },
posts: { author: r.one.users({ from: r.posts.authorId, to: r.users.id }) },
}));
export type Post = typeof posts.$inferSelect;
Column keys map to snake_case SQL automatically (no per-column name strings). A primary key is pk() (auto-increment integer, id: number) or uuidPk() (id: string). createdAt() defaults to now at the database level.
Migrations
Drizzle has no client to generate. generate turns your schema into SQL; migrate applies it.
npm run db:generate # webjs db generate -> drizzle-kit generate (schema to SQL) npm run db:migrate # webjs db migrate -> drizzle-kit migrate (apply)
Both webjs dev and webjs start run webjs db migrate before serving (the webjs.dev.before / webjs.start.before steps, idempotent), so after you db:generate a migration it is applied on the next boot with no manual db:migrate, and a deploy applies pending migrations before serving. generate stays a deliberate, manual step (it authors migration files from a schema diff), never auto-run on boot.
The connection
// db/connection.server.ts (SQLite, runtime-neutral)
import * as schema from './schema.server.ts';
const url = process.env.DATABASE_URL?.replace(/^file:/, '') ?? 'db/dev.db';
const g = globalThis as unknown as { __webjs_db?: unknown };
// node:sqlite and bun:sqlite default busy_timeout to 0, so a contended write
// throws "database is locked". Set a 5s wait (+ WAL) so it waits instead.
function tune<T extends { exec(sql: string): unknown }>(client: T): T {
client.exec('PRAGMA busy_timeout = 5000');
client.exec('PRAGMA journal_mode = WAL');
return client;
}
async function open() {
if ((globalThis as { Bun?: unknown }).Bun) {
const { Database } = await import('bun:sqlite');
const { drizzle } = await import('drizzle-orm/bun-sqlite');
return drizzle({ client: tune(new Database(url)), relations: schema.relations });
}
const { DatabaseSync } = await import('node:sqlite');
const { drizzle } = await import('drizzle-orm/node-sqlite');
return drizzle({ client: tune(new DatabaseSync(url)), relations: schema.relations });
}
export const db = (g.__webjs_db ??= await open()) as Awaited<ReturnType<typeof open>>;
The globalThis cache reuses one connection across dev-server reloads. This is the only file that opens the driver, and it is server-only (.server.ts), so it never reaches the browser.
Queries and mutations
Reads use the relational query builder with object filters; writes use the builder with .returning() (both SQLite and Postgres support it).
// modules/posts/queries/list-posts.server.ts
'use server';
import { db } from '#db/connection.server.ts';
export async function listPosts() {
return db.query.posts.findMany({
orderBy: { createdAt: 'desc' },
with: { author: { columns: { name: true } } },
});
}
// modules/posts/actions/create-post.server.ts
'use server';
import { db } from '#db/connection.server.ts';
import { posts } from '#db/schema.server.ts';
export async function createPost(input: { slug: string; title: string; body: string; authorId: number }) {
const [row] = await db.insert(posts).values(input).returning();
return { success: true as const, data: row };
}
Import the query or action from a page or component as a normal import; WebJs rewrites it into a typed RPC stub on the client.
// app/page.ts
import { listPosts } from '#modules/posts/queries/list-posts.server.ts';
export default async function Home() {
const posts = await listPosts();
return html`<ul>${posts.map(p => html`<li>${p.title}</li>`)}</ul>`;
}
Why the .server.ts boundary? Page modules (and layouts, loading, error, not-found, plus all components) load in the browser so transitively imported components register. A top-level import of db/connection.server.ts would pull the DB driver (pg, or the built-in node:sqlite, which need Node APIs) into the browser graph and crash. The .server.{js,ts} extension lets the framework rewrite the import into an RPC stub; the driver and your DB code never reach the client. The rule across the framework: server-only code goes in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components.
Type safety
Types are inferred from the schema, never hand-written. typeof posts.$inferSelect is the row type; a .ts server action's return type flows through the RPC boundary to the client, and webjs's rich-type serializer keeps a Date a Date on both sides.
Switching to Postgres
Scaffold with the dialect you want:
webjs create my-app --db postgres
That writes the Postgres variants of db/columns.server.ts and db/connection.server.ts (and the pg driver). Your schema.server.ts, queries, and actions are unchanged. To move an existing app, swap those two files for the Postgres variants and point DATABASE_URL at your Postgres instance. Migrations are generated per dialect, and runtime behaviour differs (case-sensitivity, constraints), so run your tests against the production engine before relying on a dev-SQLite / prod-Postgres setup.
CLI
webjs db generate # schema -> SQL migration (drizzle-kit generate) webjs db migrate # apply pending migrations (drizzle-kit migrate) webjs db push # push the schema straight to the dev DB (drizzle-kit push) webjs db studio # visual DB browser (drizzle-kit studio) webjs db seed # run db/seed.server.ts