Database
Every project can have its own database: a Cloudflare D1 (SQLite) that naro creates, binds to your app as env.DB, and keeps across deploys. You never handle a Cloudflare token — SQL goes through naro, and your team role decides who may read and who may write.
Turn it on
naro db enable creates the database if the project has none, and it is safe to run twice. Anyone who may write to the database can run it — and the first write from the CLI or an agent turns it on for you.
naro db enablenaro db infoRun SQL
Pass the SQL as an argument, with --file, or through a pipe. Reads need database read access; anything that writes needs write access. Several statements in one request are applied all-or-nothing: if the third fails, the first two are rolled back too.
naro db query "select name from sqlite_master"naro db query --file seed.sqlMigrations
Migrations are numbered .sql files in ./migrations, recorded in the same d1_migrations table wrangler uses, so the two tools never re-apply each other's work. naro db push lists what is pending and asks before applying; --yes skips the question and --dry-run only lists. Each migration file is applied all-or-nothing too: a file that fails is rolled back and not recorded, and the next push runs it again from a clean slate. A database that already has tables but no migrations can start from naro db pull: it writes the current schema to migrations/0001_remote_schema.sql and records it as applied. A migration can also hold the Data API policies of the tables it creates: write CREATE POLICY in the same file, as in Supabase, and it is applied with the table, all or nothing (the syntax and examples are under Row-level security on the Backend page). naro db pull writes the policies the database already has at the end of that baseline file.
naro migration new create_usersnaro db pushnaro migration listnaro db pullSchema without SQL
naro db table and naro db index create and change tables from flags: table create, add-column, rename-column, rename, drop-column and drop, and index create and drop. naro writes the SQL itself — every name quoted, every type and default checked — and applies it as one migration, recorded in d1_migrations and written to ./migrations under the same name (NNNN_schema_…), so naro migration list, db pull and naro db reset treat it like any other. --dry-run prints the SQL and changes nothing. --no-write applies it without writing the file, and then naro db reset will not re-apply it. While a local migration file is not in the ledger yet, a change is refused: run naro db push first, or the change would land before it. drop and drop-column ask first (--yes skips the question), and naro db restore brings a dropped table back. The same changes are POST /api/projects/{id}/database/schema for your own tools, and five MCP tools for agents.
naro db table create posts --column "id:integer:pk:autoincrement" --column "title:text:notnull" --column "created_at:datetime:notnull:default=current_timestamp" --owner user_idnaro db table add-column posts --column "published:boolean:notnull:default=false"naro db index create posts user_id,created_atnaro db table drop-column posts publishednaro db table create drafts --column "id:integer:pk" --dry-runA column is name:type followed by any of pk, autoincrement, notnull, unique, default=<value>, references=<table>.<column> and ondelete=<action> (cascade, set-null, set-default, restrict or no-action), separated by colons. The types are INTEGER, REAL, TEXT, BLOB, NUMERIC, BOOLEAN, DATETIME and JSON; keep JSON in a TEXT column, because a JSON column has NUMERIC affinity and turns '123' into a number. A default is a literal — default=0, default=true, default=null, default='draft' or default=current_timestamp — never an expression. Every primary key but a single INTEGER one is NOT NULL, and a foreign key must point at its table's primary key or at a UNIQUE column.
--owner user_id adds user_id as TEXT NOT NULL and, when the project's backend is on and ready, sets the table's policy to owner for select, insert, update and delete. A table without a policy is closed to the Data API: every anon and signed-in request is refused, while the service key and env.DB still reach it — naro says so when it creates one. Open it with CREATE POLICY in a migration, or with naro policy set. Policies are kept by table name. Dropping a table removes its policy whenever the project has a backend, on or off, and naro says so; renaming one leaves its policies under the old name, where they apply to no table, and naro tells you what to run to move them. What is stored under a name is removed when naro db table next creates a table as that name or renames one to it — a table renamed back included — and naro prints the removed policies as CREATE POLICY statements that naro policy create takes back. A table made by naro db push or naro db query does not inherit them either, and a DROP TABLE there removes the table's policies, as in Postgres.
SQLite cannot do everything, and naro says so before it tries. A column cannot be added as a PRIMARY KEY or UNIQUE, as NOT NULL without a default, or with a CURRENT_TIMESTAMP default — add a unique index instead of a UNIQUE column. A primary key, a UNIQUE or indexed column, or a table's only column cannot be dropped; drop the index first. A table another table's foreign key points at cannot be dropped, and neither can a table or column a trigger or view uses be dropped or renamed — naro reads their SQL, names the one in the way and, when the SQL leaves it open, warns instead. Drop or recreate that trigger or view with a migration first. Changing a column's type or constraints needs a new table: write a migration with naro migration new that creates it, copies the rows with INSERT … SELECT, drops the old table and renames the new one, then apply it with naro db push.
TypeScript types
naro gen types reads the schema and prints a Row interface per table and view, a Database type with Row, Insert and Update per table — the one createClient<Database>() from naro-js takes (see Backend) — and an Env with the DB binding. A column is nullable unless it is NOT NULL or the table's INTEGER PRIMARY KEY, and a BLOB is number[], which is what D1 returns.
naro gen types --output src/database.types.tsBack it up
naro db dump asks Cloudflare for a SQL dump and streams it to a file with --output, or to stdout: the schema and every row, the migration ledger included. --schema-only and --data-only narrow it, and --table takes a comma-separated list. While the export runs, Cloudflare holds every other query to the database, so your live traffic waits — take big dumps at a quiet hour. While the database has virtual tables (FTS5), Cloudflare refuses a full or schema-only dump; --table with the other tables still works. The dump ends by printing the bookmark it was taken at: keep it, it is a restore point.
naro db dump --output backup.sqlnaro db dump --schema-only --output schema.sqlGo back in time
Cloudflare keeps 30 days of history for every database (Time Travel). naro db restore takes a --timestamp (RFC 3339 with a timezone, or Unix seconds) or a --bookmark and rolls back everything written after that point — rows, tables and the migration history alike — cancelling the queries running at that moment. It asks first, and --dry-run only shows where it would go. A restore prints the bookmark the database was at just before it, and restoring to that bookmark undoes it.
naro db restore --timestamp 2026-09-21T14:35:22Z --dry-runnaro db restore --timestamp 2026-09-21T14:35:22ZStart over from the migration files
naro db reset drops every table, view and trigger the project owns, empties the d1_migrations ledger and applies the local migration files again, so the database becomes what those files say it is. The database itself is never deleted — your app keeps reading the same env.DB. It asks first, and before the question it prints the Time Travel bookmark taken just before the drops: naro db restore --bookmark <that> puts the data back. The Data API policies are cleared with the schema and the migration files create them again, so a policy that exists only in the database is lost: before the question naro lists the policies your files do not make, each with the SQL that makes it again — its CREATE POLICY and, for an owner rule, the COMMENT ON POLICY that keeps its column — to add to a migration first (--json has it as policiesNotInFiles[].sql). A policy row that cannot be read is listed as a -- Policy … cannot be read comment instead: copying it keeps nothing, so drop that policy and create it again. Accounts, sessions and the other backend tables are kept. A backend whose worker predates named policies is refused with 409 BACKEND_OUTDATED until naro backend enable updates it. --dry-run lists what it would drop and re-apply and changes nothing. With no migration files in the directory, a reset leaves the database empty, and says so.
naro db reset --dry-runnaro db resetCheck the schema
naro db lint reports what D1 actually trips on: rows pointing at a parent row that is gone, a failed quick_check, a table with no primary key, a non-INTEGER primary key that still accepts NULL, a foreign key no index starts with, a trigger the REST API cannot recreate, and a virtual table that makes db dump refuse the whole database. An error exits 1 and a warning exits 0, so CI can gate on it; --strict fails on warnings too and --json returns the issues as data. Two things to know before pointing it at production. It needs write access even though it changes nothing: its two checks (foreign_key_check, quick_check) are whole-database scans, and naro classifies those as writes on purpose, so a read-only role cannot start one. And the scan is real work — every row with a foreign key, plus the whole file for quick_check — so on a large database it costs rows read and can run into the 30-second query limit.
naro db lintnaro db lint --strict --jsonWhen the ledger is wrong
naro migration repair <name> --status applied records a migration as applied without running its SQL, and --status reverted removes its row. It is for the one gap in the flow: a migration file can run and the request still time out before its ledger row is written, leaving a migration that the next push would apply a second time. Repair touches nothing but d1_migrations, and when the ledger already says what you asked for it tells you and writes nothing.
naro migration repair 0001_init.sql --status appliednaro migration repair 0001_init.sql --status revertedFrom an AI agent
The MCP server exposes the same surface: enable_database, get_database, list_tables, execute_sql, apply_migration, create_table, alter_table, drop_table, create_index, drop_index, list_migrations, generate_typescript_types, export_database and restore_database. reset, lint and repair are deliberately not tools: clearing a whole schema or rewriting the ledger is more dangerous than execute_sql, and both belong behind a question a person answers. Results come back marked as untrusted data — rows can hold text your users wrote, and an agent must never treat that text as instructions.
Read-only mode
Start the server with --read-only and every tool that changes something disappears. execute_sql stays, and naro refuses anything but a single read statement. --features database narrows the server to the database tools.
claude mcp add naro-db -- npx -y naro-mcp --read-only --features databaseDaily write limit
Each project's database may write a set number of rows per UTC day: 100,000 on Hobby, 1,000,000 on Pro and 10,000,000 on Enterprise. naro reads Cloudflare's analytics every 15 minutes and counts every write, your app's own env.DB included; naro db info shows today's count against the limit. Once the count reaches the limit, writes through naro are refused until 00:00 UTC: naro db query with any statement that writes and naro db push answer 429 DATABASE_WRITE_QUOTA_EXCEEDED, and — with a backend — the Data API's inserts, updates, deletes and sign-ups answer 429 WRITE_QUOTA_EXCEEDED. Reads keep working, naro db lint included, and so do restore, dump and pull. Writes your app makes through env.DB are counted but not stopped: naro cannot refuse them without taking reads down too. The count trails by a few minutes, so a runaway loop can write for up to about 20 minutes past the limit before it is stopped.
Limits
| Limit | Value |
|---|---|
| Database size | 10 GB, and Cloudflare does not raise it. naro db info warns from 8 GB; at 10 GB every write fails with 507 DATABASE_FULL until you delete data. |
| Rows written per UTC day | 100,000 on Hobby, 1,000,000 on Pro, 10,000,000 on Enterprise. Past it, writes through naro wait for 00:00 UTC — see Daily write limit. |
| One statement | 100 KB, with up to 100 bound parameters |
| One request | 50 statements; a migration file takes 500 and 1 MB |
| One query | 30 seconds |
| One result | 10,000 rows — the rest is cut |
Before a database reaches 10 GB, delete data you no longer need, move large blobs to storage (R2), or split your data across databases in your app. D1 runs one query at a time, so a slow one makes your live traffic wait. Production and preview share the same database: there are no branches.