Database
Agent-native apps use Drizzle ORM and support portable SQL backends. For anything beyond local development, connect a persistent SQL database โ Postgres, libSQL/Turso, or another Drizzle-compatible backend โ by setting DATABASE_URL. When that variable is unset, the app falls back to a zero-config local SQLite file so you can start developing immediately. For local development that should behave like Postgres without running a separate database server, opt into PGlite with DATABASE_URL=pglite:./data/pglite.
dialect auto-detected
Neon ยท SupabaselibSQL / TursoCloudflare D1SQLite file
unset = local dev onlyPGlite
local Postgres opt-in
App code uses the framework's dialect-agnostic helpers. The dialect is auto-detected from DATABASE_URL at runtime; unset means a local SQLite file.
Local default: SQLite file
When DATABASE_URL is not set, the app creates a SQLite database at data/app.db. This is the zero-config default for local development โ no setup required. It is meant for development only; for production, set DATABASE_URL to a persistent SQL database.
Do not rely on that local file for deployed apps. Containers, serverless functions, and preview environments may reset their filesystem, which means a local SQLite file can disappear between restarts. Set DATABASE_URL to a persistent hosted database before production use.
Local Postgres Opt-In: PGlite
Install the optional PGlite package, then set DATABASE_URL=pglite:./data/pglite to run the app against PGlite, a local WASM Postgres database:
pnpm add @electric-sql/pglite@^0.5.3This keeps local development on the Postgres dialect, including Postgres schema helpers and migrations, without requiring Docker or a hosted database.
PGlite is still local development storage. Treat it like the SQLite fallback for durability and sharing: use it to test Postgres-shaped behavior on your machine, then set DATABASE_URL to a persistent hosted database for production, previews, or any shared environment.
Connecting a Production Database
Set DATABASE_URL in your .env file or deploy-provider environment to connect a hosted database. Turso is not required; use whichever Drizzle-compatible SQL backend fits your deployment:
# Neon Postgres
DATABASE_URL=postgres://user:[email protected]/mydb?sslmode=require
# Supabase Postgres
DATABASE_URL=postgres://postgres.xxxx:[email protected]:6543/postgres
# Plain Postgres
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
# Local PGlite (Postgres dialect, local development only)
DATABASE_URL=pglite:./data/pglite
# Turso (libSQL)
DATABASE_URL=libsql://my-db-org.turso.io
DATABASE_AUTH_TOKEN=your-tokenThe framework auto-detects the dialect from the URL and configures Drizzle accordingly. The built-in adapters cover Postgres URLs, local PGlite URLs, libSQL/Turso URLs, SQLite file URLs, and Cloudflare D1 bindings. Common production choices include Neon, Supabase, Turso/libSQL, plain Postgres, durable SQLite, and Builder.io-managed environments when available.
Builder.io Managed Database
Planned (not yet available): when connected to Builder.io, your app will be able to use a managed database provisioned automatically, with no connection strings required.
Where the DB Client Lives
Each template creates a lazy, singleton Drizzle client by calling createGetDb(schema) from @agent-native/core/db. The canonical location is server/db/index.ts:
import { createGetDb } from "@agent-native/core/db";
import * as schema from "./schema.js";
export const getDb = createGetDb(schema);Import getDb from this template-local path โ ../../server/db/index.js in routes, ../server/db/index.js in actions โ rather than from @agent-native/core directly. The core export returns a generic untyped instance; the template's getDb() carries your schema types. See Server for how actions and custom routes each import it.
Dialect-Agnostic Schema And Queries
App database code should use Drizzle's schema and query DSL so it can run across providers. Never write SQLite-only syntax (INSERT OR REPLACE, AUTOINCREMENT, datetime('now')) or Postgres-only syntax in product code.
Use the framework's schema helpers from @agent-native/core/db/schema:
import { table, text, integer, real, now } from "@agent-native/core/db/schema";
export const tasks = table("tasks", {
id: text("id").primaryKey(),
title: text("title").notNull(),
priority: integer("priority").notNull().default(0),
weight: real("weight"),
done: integer("done", { mode: "boolean" }).notNull().default(false),
ownerEmail: text("owner_email").notNull(),
createdAt: text("created_at").notNull().default(now()),
});| Helper | Purpose |
|---|---|
table |
Define a table โ delegates to pgTable or sqliteTable |
text |
Text column, supports { enum: [...] } |
integer |
Integer column, { mode: "boolean" } maps to Postgres boolean |
real |
Float column โ real on SQLite, double precision on Postgres |
now |
Dialect-agnostic current timestamp for .default(now()) |
The tasks table above defines the same columns on every backend:
Domain table. Add owner_email (or ...ownableColumns()) so SQL-level scoping can filter rows to the authenticated user.
id | text | PK |
title | text | |
priority | integer | default 0 |
weight | real | nullable |
done | integer (boolean mode) | default false; maps to a Postgres boolean |
owner_email | text | enables data scoping |
created_at | text | default now() |
Defined once with the framework helpers; the dialect is chosen at runtime from DATABASE_URL.
Never import from drizzle-orm/sqlite-core or drizzle-orm/pg-core directly. Always use @agent-native/core/db/schema.
Tables that store user-facing data must include an owner_email column so the framework's SQL-level scoping can filter rows to the authenticated user โ see Security. Tables that also support sharing with other users or orgs should spread ...ownableColumns() instead, which adds owner_email, org_id, and visibility in one call โ see Sharing.
For reads and writes, use Drizzle's query builder and portable operators from drizzle-orm:
import { and, desc, eq } from "drizzle-orm";
import { getDb } from "../server/db/index.js";
import { tasks } from "../server/db/schema.js";
const db = getDb();
const openTasks = await db
.select()
.from(tasks)
.where(and(eq(tasks.ownerEmail, userEmail), eq(tasks.done, false)))
.orderBy(desc(tasks.createdAt));
await db.update(tasks).set({ done: true }).where(eq(tasks.id, taskId));Raw SQL Escape Hatches
Raw SQL is not the default app-code API. Use it only for additive migrations, health checks, carefully reviewed advanced queries that Drizzle cannot express, or one-off maintenance. Keep it parameterized and dialect-agnostic. For timestamps in Drizzle schemas, prefer .default(now()); for migration SQL, use runMigrations() so framework-supported compatibility rewrites and dialect-gated statements stay centralized.
For cases where you truly need raw SQL outside of Drizzle queries:
getDbExec()โ auto-converts?params to$1for PostgresisPostgres()โ runtime dialect checkintType()โ returns the correct integer type for the current dialect
Migrations and Schema Updates
In hosted environments, multiple deployment previews, branches, and the production server share the same underlying database. Therefore, database schema updates must follow strict constraints to avoid data loss and service disruption.
The "Zero Destructive Changes" Rule
All database schema updates must be strictly additive.
- Do not drop tables or columns.
- Do not rename tables or columns. Renaming a column or table looks like a drop + create sequence to Drizzle, which will permanently delete your existing production data.
- If a column needs to be renamed or replaced, add the new column alongside the old one, update your application code to read from/write to both, migrate the data, and only retire the old column in a later release once no active deployments are referencing it.
Never run drizzle-kit push against a production database. Template
database schemas only define app-specific domain tables; they do not define
central framework tables (user, session, application_state, etc.). If
you run drizzle-kit push against production, Drizzle will detect these
framework tables as "not in schema" and attempt to drop them, causing
immediate system-wide failure and data loss.
Safe Migration Path
Instead of pushing directly, schema changes should be applied via SQL migrations executed at application startup. Implement additive migrations within a server plugin (e.g., server/plugins/db.ts) by invoking the framework's runMigrations() helper:
ADD COLUMN IF NOT EXISTS is safe to re-run and never drops data. Renames look like drop+create to Drizzle, so add-then-migrate instead.
Pass an object keyed by dialect to run different SQL per backend. Make the other key a no-op (SELECT 1) for Postgres-only or SQLite-only features.
Each app tracks its own applied versions so migrations are idempotent across restarts and instances.
Environment Variables
| Variable | Purpose |
|---|---|
DATABASE_URL |
Persistent SQL connection string (unset = local SQLite; pglite:./data/pglite = local Postgres opt-in) |
DATABASE_AUTH_TOKEN |
Auth token for providers that require a separate token, such as Turso/libSQL |
What's next
- Security โ Data Scoping โ how
owner_emailand access helpers scope reads and writes - Sharing โ
ownableColumns()and the visibility model for shared resources - Server โ how actions and custom routes each import
getDb - Deployment โ connecting a persistent database per deploy target
- Actions โ a complete, paste-ready action that reads and writes through
getDb/schema