JORVEL

Database & backend

jorvel add db scaffolds a Drizzle ORM backend into an app — schema, a typed client, drizzle-kit migrations, a seed script, and an example server data loader wired to defineLoader. Two drivers: sqlite (better-sqlite3, zero-infra local file) and libsql (Turso / edge-friendly).

Scaffold

bash
# into the host app, SQLite (default)
jorvel add db

# into a specific app, libsql/Turso
jorvel add db dashboard --driver libsql

It writes (TypeScript app shown; a JS app gets .js equivalents):

FilePurpose
src/db/schema.tsDrizzle tables (users, posts) + inferred User/Post types
src/db/client.tsThe db client bound to the driver
src/db/seed.tsSample-data seeder
src/server/posts.data.tsA defineLoader reading rows — hydration-ready
drizzle.config.tsdrizzle-kit config (schema path, out dir, credentials)

Plus drizzle-orm + the driver added to the app's dependencies, db:generate / db:migrate / db:push / db:studio / db:seed scripts, and a DATABASE_URL entry appended to .env.example.

Generate & migrate

bash
pnpm install                 # pick up the new deps
cd apps/shell
pnpm db:generate             # emit SQL migration into ./drizzle
pnpm db:migrate              # apply it to the database
pnpm db:seed                 # optional: insert sample rows
pnpm db:studio               # browse data in Drizzle Studio

Schema

apps/shell/src/db/schema.ts
ts
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
});

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  authorId: integer('author_id').notNull().references(() => users.id),
  title: text('title').notNull(),
  body: text('body').notNull(),
});

export type User = typeof users.$inferSelect;
export type Post = typeof posts.$inferSelect;

Read in a loader

The generated data module pairs the query with a loader, so the rows are fetched on the server and hydrated without a second client request.

apps/shell/src/server/posts.data.ts
ts
import { defineLoader } from '@jorvel/ssr';
import { desc } from 'drizzle-orm';
import { db } from '../db/client.js';
import { posts } from '../db/schema.js';

export const recentPostsLoader = defineLoader({
  key: 'recentPosts',
  load: async () => db.select().from(posts).orderBy(desc(posts.id)).limit(20),
});
tsx
import { useLoaderData } from '@jorvel/ssr';
import type { Post } from '../db/schema.js';

export default function Posts() {
  const posts = useLoaderData<Post[]>('recentPosts') ?? [];
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}

Write with an action

Mutations go through a server action — symmetric to the loader.

ts
import { defineAction } from '@jorvel/runtime';
import { db } from '../db/client.js';
import { posts } from '../db/schema.js';

export const createPost = defineAction(async (input: { authorId: number; title: string; body: string }) => {
  const [row] = await db.insert(posts).values(input).returning();
  return row;
});

Driver choice

sqlite (better-sqlite3) is synchronous, fast, and perfect for Node servers and local dev. libsql targets Turso and edge runtimes (HTTP-based) — use it when you deploy to Cloudflare/Vercel edge or want a managed replica.

Server-only

src/db/* and src/server/* import a native/HTTP driver and read DATABASE_URL — keep them out of client bundles. Import them only from loaders, actions, and SSR/edge handlers, never from a component that ships to the browser.