close

Database

Every agent-native app stores its state in SQL. The UI and the agent both read and write the same tables through the same actions, using the same Drizzle ORM client. Live sync uses SSE with polling fallback, so changes appear in the other surface without a manual refresh.

How data flows to the database
Browser / UI
Agent loop
Actions defineAction getDb() · Drizzle ORM
SQL database
dialect from DATABASE_URL

The UI and the agent both reach the database through the same Actions layer. Both callers use the same getDb() client and the same SQL tables. There is no separate backend for each.

Hosting Options

The app detects which backend to use from DATABASE_URL. When the variable is unset, it falls back to a local SQLite file so you can start without any setup.

Default: SQLite file

When DATABASE_URL is not set, the app creates a SQLite database at data/app.db. No configuration required. Start the dev server and the file is created on first run.

This is for local development only. Containers, serverless functions, and preview environments may reset their filesystem between restarts, so a local SQLite file can disappear. Set DATABASE_URL to a persistent hosted database before deploying.

Local Postgres: PGlite

To develop locally against the Postgres dialect without Docker or a hosted database, install the optional PGlite package and set DATABASE_URL:

pnpm add @electric-sql/pglite@^0.5.3
DATABASE_URL=pglite:./data/pglite

PGlite runs an in-process WASM Postgres database. It lets you catch Postgres-only schema issues before deploying, while keeping setup as simple as the SQLite default. Like SQLite, it is local-only storage. Do not use it for production or shared environments.

Production database

Set DATABASE_URL in your .env file or deploy-provider environment to connect a hosted database:

# 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

# Turso (libSQL). Also requires DATABASE_AUTH_TOKEN.
DATABASE_URL=libsql://my-db-org.turso.io

The framework auto-detects the dialect from the URL prefix and configures Drizzle accordingly.

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.

Setting Up the Database

An app that uses the database needs three files:

  • server/db/schema.ts: table definitions
  • server/db/index.ts: the typed DB client singleton
  • server/plugins/db.ts: app-owned migrations for local startup

1. Define your schema

Import schema helpers from @agent-native/core/db/schema. Never import from drizzle-orm/sqlite-core or drizzle-orm/pg-core directly. The framework helpers produce dialect-agnostic definitions that work across all supported backends.

server/db/schema.ts
import { integer, now, table, text } 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),
  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; dispatches to pgTable or sqliteTable
text Text column, supports { enum: [...] }
integer Integer column, { mode: "boolean" } maps to Postgres boolean
real Float column. Maps to real on SQLite, double precision on Postgres
now Dialect-agnostic current timestamp for .default(now())
The tasks table

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
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.

Tables that store per-user data must include an owner_email column so the framework can filter rows to the authenticated user. 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 Scoping Data to Users below.

2. Create the DB client

Each app creates a lazy, singleton Drizzle client by calling createGetDb(schema). The canonical location is server/db/index.ts:

server/db/index.ts
import { createGetDb } from "@agent-native/core/db";
import * as schema from "./schema.js";

export const getDb = createGetDb(schema);

createGetDb returns a getDb() function that opens the database connection on first call and returns the same typed Drizzle instance on subsequent calls. It reads DATABASE_URL at runtime to determine which backend and dialect to use.

Import getDb from this template-local path in actions and routes. Do not import from @agent-native/core directly. The core export is untyped; the local export carries your schema types.

3. Write migrations

For local development, schema changes can run through a Nitro plugin in server/plugins/db.ts. Use runMigrations from @agent-native/core/db:

A startup migration plugin
server/plugins/db.ts
1import { runMigrations } from "@agent-native/core/db";
2 
3export default runMigrations(
4 [
5 {
8 id TEXT PRIMARY KEY,
9 title TEXT NOT NULL,
10 done INTEGER NOT NULL DEFAULT 0,
11 owner_email TEXT NOT NULL,
12 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
13 )`,
14 },
15 {
16 version: 2,
18 },
19 {
20 // Dialect-gated: runs only on the matching backend.
21 version: 3,
26 },
27 ],
28 { table: "my_app_migrations" },
29);
Line 6Per-app version table

Each app tracks its own applied versions in a separate table. Use a name unique to your app so it doesn't collide with framework migrations or other apps sharing the same database.

Line 7Initial table creation

CREATE TABLE IF NOT EXISTS is safe to re-run on every restart. Put the full table definition in version 1.

Line 17Additive column adds

ADD COLUMN IF NOT EXISTS is the safe way to add columns after initial creation. Never drop or rename columns.

Lines 22–25Dialect gating

Pass an object keyed by dialect to run different SQL per backend. Use SELECT 1 as a no-op on the dialect that doesn't need the change.

runMigrations runs each pending entry in order once, records it as applied, and skips it on later runs. Give new entries a stable name; named migrations are tracked independently of their version number.

For production or serverless deployments, run migrations in a release or deploy step instead of on the first request. The Chat template provides pnpm migrate:production for this.

Give every new migration a stable name (e.g. { version: 4, name: "tasks-priority-index", sql: "..." }). The plain version number is only a position in a shared sequence: if two branches each add their own migrations at, say, version: 4, whichever branch deploys first "uses up" that version number in the bookkeeping table, and the other branch's DDL silently never runs when it merges — the table looks fully migrated even though the second branch's columns or tables don't exist. A name is tracked independently of version, so it applies exactly once regardless of what version number it shipped under or which branch merged first. Unnamed legacy migrations keep working as-is; add name to migrations you write from now on.

Never run drizzle-kit push against a production database. Template schemas only define app-specific tables; they do not include central framework tables (user, session, application_state, and others). Running drizzle-kit push against production will detect those tables as unknown and attempt to drop them, causing immediate data loss.

drizzle.config.ts at the root of each app configures drizzle-kit for local development schema inspection:

drizzle.config.ts
import { createDrizzleConfig } from "@agent-native/core/db/drizzle-config";
export default createDrizzleConfig();

Use pnpm db:generate to inspect your schema and pnpm db:push against a local database only.

4. Query in actions

Call getDb() from your actions to get the typed Drizzle client. 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 * as schema from "../server/db/schema.js";

const db = getDb();

const openTasks = await db
  .select()
  .from(schema.tasks)
  .where(
    and(eq(schema.tasks.ownerEmail, userEmail), eq(schema.tasks.done, false)),
  )
  .orderBy(desc(schema.tasks.createdAt));

await db
  .update(schema.tasks)
  .set({ done: true })
  .where(eq(schema.tasks.id, taskId));

Scoping Data to Users

All reads and writes against user-facing tables must be scoped to the authenticated user. The framework provides two patterns depending on whether the data is private or shareable.

Private data: tables that belong to one user. Add ownerEmail: text("owner_email").notNull() to the schema and include eq(table.ownerEmail, userEmail) in every query:

.where(eq(schema.tasks.ownerEmail, userEmail))

Shared resources: tables that can be shared with other users or organizations. Spread ...ownableColumns() in the schema instead of a bare owner_email. This adds owner_email, org_id, and visibility in one call, and creates a companion shares table with createSharesTable:

server/db/schema.ts
import {
  table,
  text,
  ownableColumns,
  createSharesTable,
} from "@agent-native/core/db/schema";

export const decks = table("decks", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  ...ownableColumns(),
});
export const deckShares = createSharesTable("deck_shares");

Then use accessFilter from @agent-native/core/sharing in list queries instead of a manual eq check:

import { accessFilter } from "@agent-native/core/sharing";

const rows = await db
  .select()
  .from(schema.decks)
  .where(accessFilter(schema.decks, schema.deckShares));

accessFilter builds a query that admits rows the caller owns, rows shared with their org, and rows explicitly shared with them. It does not expose rows from other users.

See Security — Data Scoping and Sharing for the full model.

SQL-Backed Sync

Agent-native does not rely on filesystem watchers or sticky in-memory state. When an action writes to the database, a sync version increments. The client useDbSync() hook polls /_agent-native/poll and invalidates React Query caches when it sees a higher version.

This works across serverless and multi-instance deployments because the database is the coordination point. If you write custom mutations outside actions, use framework helpers or emit the appropriate sync invalidation so open UIs refresh.

SQL-backed sync loop
Action / helper
mutates data
SQL databasesync version increments
useDbSync()
polls /_agent-native/poll
invalidate caches → UI refreshes

No watchers, no sticky state. A write bumps a version in SQL; every client polls the version and refetches.

The poll endpoint

Raw SQL

For advanced queries, health checks, or one-off maintenance that the Drizzle query builder can't express, use getDbExec from @agent-native/core/db:

import { getDbExec, isPostgres, intType } from "@agent-native/core/db";

const { rows } = await getDbExec().execute({
  sql: `SELECT id, title FROM tasks WHERE owner_email = ? LIMIT ?`,
  args: [userEmail, 50],
});

getDbExec auto-converts ? params to $1, $2, etc. for Postgres. Use isPostgres() to branch on dialect, and intType() to return the correct integer type for the current backend. Prefer the Drizzle query builder for normal reads and writes. Raw SQL bypasses type safety and is harder to maintain.

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_email and access helpers scope reads and writes
  • Sharing: ownableColumns() and the visibility model for shared resources
  • Plugins: the startup plugin lifecycle where migrations run
  • Actions: the surface where actions call getDb() to read and write data
  • Deployment: connecting a persistent database per deploy target
  • Real-Time Sync: useDbSync() and the full client-side sync model