데이터베이스
프로젝트마다 자기 데이터베이스를 가질 수 있다. naro가 만들고, 앱에 env.DB로 묶고, 배포가 바뀌어도 유지하는 Cloudflare D1(SQLite)이다. Cloudflare 토큰을 직접 다룰 일은 없다 — SQL은 naro를 거치고, 누가 읽고 쓸 수 있는지는 팀 역할이 정한다.
켜기
naro db enable은 프로젝트에 데이터베이스가 없으면 만든다. 두 번 실행해도 안전하다. 데이터베이스에 쓸 수 있는 사람이면 누구나 실행할 수 있고, CLI나 에이전트의 첫 쓰기가 알아서 켜 주기도 한다.
naro db enablenaro db infoSQL 실행
SQL은 인자로, --file로, 또는 파이프로 넘긴다. 읽기에는 읽기 권한이, 쓰는 문장에는 쓰기 권한이 필요하다. 한 요청의 여러 문장은 한 트랜잭션으로 적용된다. 세 번째가 실패하면 앞의 둘도 함께 되돌려진다.
naro db query "select name from sqlite_master"naro db query --file seed.sql마이그레이션
마이그레이션은 ./migrations에 있는 번호 붙은 .sql 파일이고, wrangler가 쓰는 것과 같은 d1_migrations 테이블에 기록된다. 그래서 두 도구가 서로의 작업을 다시 적용하지 않는다. naro db push는 밀린 파일을 나열하고 적용 전에 묻는다. --yes는 묻지 않고, --dry-run은 나열만 한다. 마이그레이션 파일도 통째로 적용되거나 통째로 되돌려진다. 실패한 파일은 기록되지 않고, 다음 push가 깨끗한 상태에서 그 파일을 다시 돌린다. 테이블은 있는데 마이그레이션이 없는 데이터베이스라면 naro db pull로 시작한다. 지금 스키마를 migrations/0001_remote_schema.sql로 쓰고 적용된 것으로 기록한다. 마이그레이션에는 그 파일이 만드는 테이블의 Data API 정책도 쓸 수 있다. 같은 파일에 Supabase처럼 CREATE POLICY를 쓰면 테이블과 함께 통째로 적용되거나 통째로 되돌려진다(문법과 예시는 백엔드 문서의 행 수준 보안 절에 있다). naro db pull은 데이터베이스에 이미 있는 정책을 그 기준 파일의 끝에 함께 쓴다.
naro migration new create_usersnaro db pushnaro migration listnaro db pullSQL 없이 스키마 바꾸기
naro db table과 naro db index는 플래그로 테이블을 만들고 바꾼다. table은 create, add-column, rename-column, rename, drop-column, drop, index는 create와 drop이다. SQL은 naro가 직접 쓴다 — 이름은 전부 따옴표로 감싸고 타입과 기본값은 검사한다. 그리고 한 마이그레이션으로 적용해 d1_migrations에 기록하고 같은 이름(NNNN_schema_…)의 파일로 ./migrations에 쓴다. 그래서 naro migration list, db pull, naro db reset이 다른 마이그레이션과 똑같이 다룬다. --dry-run은 SQL만 찍고 아무것도 바꾸지 않는다. --no-write는 적용만 하고 파일을 쓰지 않으며, 그러면 naro db reset이 그 변경을 다시 적용하지 못한다. 원장에 아직 없는 로컬 마이그레이션 파일이 있으면 변경을 거절한다. 그 파일보다 먼저 적용되기 때문이니 naro db push부터 한다. drop과 drop-column은 먼저 묻고(--yes로 생략), 지운 테이블은 naro db restore로 되살린다. 같은 변경을 직접 만든 도구에서는 POST /api/projects/{id}/database/schema로, 에이전트에서는 MCP 툴 다섯 개로 할 수 있다.
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-run열은 name:type 뒤에 pk, autoincrement, notnull, unique, default=<값>, references=<table>.<column>, ondelete=<동작>(cascade, set-null, set-default, restrict, no-action)을 콜론으로 이어 붙인 것이다. 타입은 INTEGER, REAL, TEXT, BLOB, NUMERIC, BOOLEAN, DATETIME, JSON이다. JSON은 TEXT 열에 담는다 — JSON 열은 NUMERIC 친화도라 '123'을 수로 바꿔 저장한다. 기본값은 리터럴이다 — default=0, default=true, default=null, default='draft', default=current_timestamp. 식은 받지 않는다. 단독 INTEGER가 아닌 기본 키는 모두 NOT NULL이고, 외래 키는 부모 테이블의 기본 키나 UNIQUE 열을 가리켜야 한다.
--owner user_id는 user_id를 TEXT NOT NULL로 더하고, 프로젝트 백엔드가 켜져 있고 준비돼 있으면 테이블의 정책을 select·insert·update·delete 모두 owner로 둔다. 정책이 없는 테이블은 Data API에 닫혀 있다. anon과 로그인한 요청은 전부 거절되고 service 키와 env.DB만 닿는다 — naro가 테이블을 만들 때 그렇다고 알려 준다. 마이그레이션의 CREATE POLICY나 naro policy set으로 연다. 정책은 테이블 이름으로 저장된다. 테이블을 지우면 프로젝트에 백엔드가 있는 한(켜져 있든 꺼져 있든) 그 정책도 지우고 naro가 그렇다고 알려 준다. 이름을 바꾸면 정책은 옛 이름에 남아 어떤 테이블에도 적용되지 않고, 옮기려면 무엇을 칠지 naro가 알려 준다. 어떤 이름에 남은 정책은 naro db table로 그 이름의 테이블을 다시 만들거나 그 이름으로 바꿀 때(바꿨던 이름을 되돌릴 때도) 먼저 지워지며, naro가 지운 정책을 naro policy create가 다시 받는 CREATE POLICY 문으로 보여 준다. naro db push나 naro db query로 만든 테이블도 남은 정책을 물려받지 않고, 거기서 DROP TABLE을 하면 Postgres처럼 그 테이블의 정책도 지워진다.
SQLite가 못 하는 것이 있고, naro는 해 보기 전에 그렇다고 말한다. 이미 있는 테이블에 PRIMARY KEY나 UNIQUE 열, 기본값 없는 NOT NULL 열, CURRENT_TIMESTAMP 기본값 열은 더할 수 없다 — UNIQUE 열 대신 unique 인덱스를 더한다. 기본 키, UNIQUE 열, 인덱스가 쓰는 열, 테이블의 유일한 열은 지울 수 없다. 인덱스를 먼저 지운다. 다른 테이블의 외래 키가 가리키는 테이블도 지울 수 없고, 트리거나 뷰가 쓰는 테이블·열은 지우거나 이름을 바꿀 수 없다 — naro가 그 SQL을 읽어 걸리는 것을 이름으로 알려 주고, SQL로 확실히 알 수 없으면 거절 대신 경고한다. 그 트리거나 뷰를 먼저 마이그레이션으로 지우거나 다시 만든다. 열의 타입이나 제약을 바꾸려면 테이블을 새로 만들어야 한다: naro migration new로 마이그레이션을 만들어 새 테이블을 만들고 INSERT … SELECT로 행을 옮기고 옛 테이블을 지운 뒤 새 테이블의 이름을 바꾸고, naro db push로 적용한다.
TypeScript 타입
naro gen types는 스키마를 읽어 테이블과 뷰마다 Row 인터페이스 하나, 테이블마다 Row·Insert·Update를 담은 Database 타입(naro-js의 createClient<Database>()가 받는 것 — Backend 참고), DB 바인딩이 든 Env를 찍는다. NOT NULL이거나 테이블의 INTEGER PRIMARY KEY가 아니면 그 컬럼은 null일 수 있고, BLOB은 D1이 돌려주는 대로 number[]다.
naro gen types --output src/database.types.ts백업
naro db dump는 Cloudflare에 SQL 덤프를 요청해 --output 파일이나 stdout으로 흘려 쓴다. 스키마와 모든 행, 마이그레이션 원장까지 들어 있다. --schema-only와 --data-only로 좁힐 수 있고, --table은 쉼표로 여러 개를 받는다. export가 도는 동안 Cloudflare가 이 데이터베이스의 다른 쿼리를 전부 붙잡아 두므로 라이브 트래픽이 기다린다 — 큰 덤프는 한가한 시간에 뜬다. 가상 테이블(FTS5)이 있으면 Cloudflare가 전체·스키마 덤프를 거절한다. 나머지 테이블을 --table로 고르면 된다. 덤프는 마지막에 그 시점의 북마크를 찍는다. 복원 지점이니 남겨 둔다.
naro db dump --output backup.sqlnaro db dump --schema-only --output schema.sql되돌리기
Cloudflare는 모든 데이터베이스의 이력을 30일 동안 보관한다(Time Travel). naro db restore에 --timestamp(시간대가 붙은 RFC 3339, 또는 Unix 초)나 --bookmark를 주면 그 뒤에 쓴 것을 전부 되감는다. 행과 테이블은 물론 마이그레이션 이력도 함께 되감기고, 그 순간 돌던 쿼리는 취소된다. 실행 전에 묻고, --dry-run은 어디로 돌아갈지만 보여 준다. 복원하면 직전 북마크를 알려 주는데, 그 북마크로 다시 복원하면 방금 한 복원이 취소된다.
naro db restore --timestamp 2026-09-21T14:35:22Z --dry-runnaro db restore --timestamp 2026-09-21T14:35:22Z마이그레이션 파일이 말하는 상태로
naro db reset은 프로젝트가 가진 테이블·뷰·트리거를 전부 지우고 d1_migrations 원장을 비운 다음, 로컬 마이그레이션 파일을 처음부터 다시 적용한다. 그래서 데이터베이스는 그 파일들이 말하는 상태가 된다. 데이터베이스 자체는 지우지 않는다 — 앱은 같은 env.DB를 계속 읽는다. 실행 전에 묻고, 묻기 전에 지우기 직전의 Time Travel 북마크를 찍는다. naro db restore --bookmark <그 값>이 데이터를 되돌린다. Data API 정책도 스키마와 함께 비워지고 마이그레이션 파일이 다시 만든다. 그래서 데이터베이스에만 있는 정책은 사라지는데, 묻기 전에 파일이 만들지 않는 정책을 그 정책을 다시 만드는 SQL과 함께 나열해 주니 — CREATE POLICY와, owner 규칙이면 그 컬럼을 지키는 COMMENT ON POLICY — 지키려면 먼저 그 SQL을 마이그레이션에 넣는다(--json은 policiesNotInFiles[].sql). 읽을 수 없는 정책 행은 SQL 대신 -- Policy … cannot be read 주석으로 나온다. 옮겨 적어도 남는 게 없으니 그 정책을 지우고 다시 만든다. 계정·세션과 나머지 백엔드 테이블은 그대로 둔다. 워커가 이름 있는 정책보다 오래된 백엔드는 naro backend enable로 갱신할 때까지 409 BACKEND_OUTDATED로 거절된다. --dry-run은 무엇을 지우고 무엇을 다시 적용할지만 보여 주고 아무것도 바꾸지 않는다. 디렉터리에 마이그레이션 파일이 하나도 없으면 reset은 빈 데이터베이스를 남기고, 그렇다고 알려 준다.
naro db reset --dry-runnaro db reset스키마 점검
naro db lint는 D1에서 실제로 물리는 것만 본다. 사라진 부모 행을 가리키는 행, 실패한 quick_check, 기본 키가 없는 테이블, INTEGER가 아니면서 NULL을 받는 기본 키, 어떤 인덱스의 선두 컬럼도 아닌 외래 키, REST API로는 다시 만들 수 없는 트리거, db dump가 전체 덤프를 거절하게 만드는 가상 테이블. error가 있으면 종료 코드 1, warn만이면 0이라 CI 게이트로 쓸 수 있고, --strict는 warn도 실패로 만들며 --json은 결과를 데이터로 준다. 프로덕션에 대고 돌리기 전에 알아 둘 것이 둘 있다. 아무것도 바꾸지 않는데도 쓰기 권한이 필요하다 — 두 검사(foreign_key_check, quick_check)가 데이터베이스 전체 스캔이라 naro가 의도적으로 쓰기로 분류한다. 읽기 전용 역할이 전체 스캔을 시작하지 못하게 하기 위해서다. 그리고 그 스캔은 진짜 일이다. 외래 키가 있는 행을 전부, quick_check는 파일 전체를 훑으므로 큰 데이터베이스에서는 읽은 행이 과금되고 30초 쿼리 제한에 닿을 수 있다.
naro db lintnaro db lint --strict --json원장이 어긋났을 때
naro migration repair <이름> --status applied는 SQL을 실행하지 않고 그 마이그레이션을 적용된 것으로 기록하고, --status reverted는 그 행을 지운다. 흐름에 난 구멍 하나 때문에 있는 명령이다 — 마이그레이션 파일은 돌았는데 원장 행을 쓰기 전에 요청이 시간 초과되면, 다음 push가 같은 마이그레이션을 한 번 더 적용하려 든다. repair는 d1_migrations 말고는 아무것도 건드리지 않고, 원장이 이미 요청한 대로 되어 있으면 그렇다고 말하고 아무것도 쓰지 않는다.
naro migration repair 0001_init.sql --status appliednaro migration repair 0001_init.sql --status revertedAI 에이전트에서
MCP 서버도 같은 기능을 연다. 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, restore_database. reset·lint·repair는 일부러 툴로 열지 않았다. 스키마를 통째로 비우거나 원장을 고쳐 쓰는 건 execute_sql보다 위험하고, 둘 다 사람이 답하는 질문 뒤에 있어야 한다. 결과는 신뢰할 수 없는 데이터로 표시되어 돌아온다 — 행에는 당신의 사용자가 쓴 글이 들어 있을 수 있고, 에이전트는 그 글을 지시로 받아들이면 안 된다.
읽기 전용 모드
서버를 --read-only로 띄우면 무언가를 바꾸는 툴이 전부 사라진다. execute_sql은 남지만 naro가 읽기 문장 하나 말고는 전부 거부한다. --features database는 서버를 데이터베이스 툴로만 좁힌다.
claude mcp add naro-db -- npx -y naro-mcp --read-only --features database하루 쓰기 한도
프로젝트 데이터베이스는 UTC 하루에 정해진 행 수까지만 쓸 수 있다. Hobby 100,000행, Pro 1,000,000행, Enterprise 10,000,000행이다. naro는 15분마다 Cloudflare 분석을 읽어 앱의 env.DB를 포함한 모든 쓰기를 세고, naro db info가 오늘 쓴 양을 한도와 함께 보여준다. 한도에 닿으면 00:00 UTC까지 naro를 거치는 쓰기가 거절된다. 쓰는 문장이 하나라도 든 naro db query와 naro db push는 429 DATABASE_WRITE_QUOTA_EXCEEDED로, 백엔드가 있으면 Data API의 insert·update·delete와 가입은 429 WRITE_QUOTA_EXCEEDED로 답한다. 읽기는 naro db lint를 포함해 계속 되고 restore·dump·pull도 된다. 앱이 env.DB로 직접 쓰는 건 세지만 막지는 않는다 — naro가 그걸 막으려면 읽기까지 끊어야 한다. 집계가 몇 분 늦게 들어오므로 폭주하는 루프는 한도를 넘고도 최대 20분쯤 더 쓴 뒤에 멈춘다.
한도
| 한도 | 값 |
|---|---|
| 데이터베이스 크기 | 10 GB이고 Cloudflare가 늘려 주지 않는다. naro db info가 8 GB부터 경고하고, 10 GB에 닿으면 데이터를 지울 때까지 모든 쓰기가 507 DATABASE_FULL로 실패한다. |
| UTC 하루에 쓰는 행 | Hobby 100,000, Pro 1,000,000, Enterprise 10,000,000. 넘으면 naro를 거치는 쓰기가 00:00 UTC까지 멈춘다 — 위 하루 쓰기 한도 참고. |
| 문장 하나 | 100 KB, 바인딩 파라미터 100개까지 |
| 요청 하나 | 50문장. 마이그레이션 파일은 500문장, 1 MB까지 |
| 쿼리 하나 | 30초 |
| 결과 하나 | 10,000행 — 나머지는 잘린다 |
10 GB에 닿기 전에 필요 없는 데이터를 지우거나, 큰 바이너리를 스토리지(R2)로 옮기거나, 앱에서 데이터를 여러 데이터베이스로 나눈다. D1은 쿼리를 한 번에 하나씩 돌리므로 느린 쿼리 하나가 라이브 트래픽을 기다리게 한다. 프로덕션과 프리뷰는 같은 데이터베이스를 쓴다. 브랜치는 없다.