Database
Database & Migrations
Define PostgreSQL schemas with Drizzle ORM in VitNode plugins, generate migrations, and execute queries.
VitNode uses PostgreSQL and Drizzle ORM. Plugins define typed schema files, compile them into SQL migrations, and query tables via c.get("db").
Quick start
1. Define the Schema
Create a database model in plugins/{plugin}/src/database/{table}.ts:
import { camelCase } from "drizzle-orm/pg-core"
export const reactions = camelCase.table.withRLS("reactions", (t) => ({
id: t.serial().primaryKey(),
itemId: t.integer().notNull(),
emoji: t.varchar({ length: 32 }).notNull(),
createdAt: t.timestamp().notNull().defaultNow(),
}))2. Query in an API Route
Access the typed Drizzle instance using c.get("db"):
import { buildRoute } from "@/api/builders"
import { reactions } from "@/database/reactions"
export const getReactionsRoute = buildRoute({
route: {
method: "get",
path: "/",
responses: { 200: { description: "List of reactions" } },
},
handler: async (c) => {
const rows = await c.get("db").select().from(reactions)
return c.json(rows)
},
})3. Build & Apply Migrations
bun run build:plugins && bun run db:migratebuild:plugins compiles your TypeScript models and exports, and db:migrate updates PostgreSQL.
Migration Commands
Run these with the VitNode CLI in the app that owns the database:
| Command | Action |
|---|---|
vitnode db generate | Shows schema changes and writes a timestamped SQL migration |
vitnode db migrate | Applies pending migrations against the target database |
vitnode db status | Checks the connection and lists pending migrations |
vitnode db push | Syncs the schema directly - development only |
If your table represents an editorial entity (articles, FAQs, products), consider Content Engine to generate your Drizzle table, API routes, and AdminCP screens from one definition.
Connection pool
VitNode talks to Postgres through postgres.js, and the default config hands it a bare connection string:
dbProvider: drizzle({
connection: POSTGRES_URL,
relations: coreRelations,
}),That means postgres.js defaults: up to 10 connections per process, idle connections kept forever (idle_timeout: 0), and automatic prepared statements (prepare: true). Perfectly fine on your laptop. In production you probably want to pick the numbers yourself.
Swap the string for an object - url plus any postgres.js option:
Long-running server
A Node server, a Docker container, a VPS. One process lives for days and handles plenty of requests at once, so give it room:
dbProvider: drizzle({
connection: POSTGRES_URL,
connection: { url: POSTGRES_URL, max: 20, idle_timeout: 20 },
relations: coreRelations,
}),| Option | Default | What it does |
|---|---|---|
max | 10 | The most connections this process will hold open. Queries beyond it wait their turn. |
idle_timeout | 0 | Seconds before an unused connection is closed. 0 never closes it; 20 frees quiet periods. |
prepare | true | Creates named prepared statements automatically. Turn it off behind a transaction pooler. |
Sizing rule of thumb: every process opens its own pool, and Postgres has a hard ceiling (SHOW max_connections; - often 100). So:
max ≈ (max_connections − headroom) ÷ number of processesKeep 10-20 connections of headroom for migrations, db:studio, backups and you poking around in psql. With max_connections = 100, 4 instances and 20 spare, that is max: 20 each. Scale out to 8 instances and the same math says max: 10 - forget to redo it and the fifth instance greets you with too many connections.
Serverless
On Vercel, Netlify and friends every concurrent request may be its own process, so max × processes grows faster than you would like. Use your provider's pooled connection string, or keep each process to a single connection with max: 1. The Vercel guide has the full setup.
Behind a transaction pooler
The Supabase pooler on port 6543, PgBouncer with pool_mode = transaction
and similar poolers hand you a different backend connection for every
transaction. A prepared statement created on one backend does not exist on
the next, so queries fail at random with prepared statement "..." does not exist. Turn automatic prepared statements off:
dbProvider: drizzle({
connection: { url: POSTGRES_URL, prepare: false },
relations: coreRelations,
}),Direct connections and session-mode poolers keep prepare: true - it is the faster option. VitNode's own locks are transaction-scoped (pg_advisory_xact_lock), so they work behind either mode.