データベース
D1 のテーブル、クエリの書き方、マイグレーションの配布と適用、スキーマの変更がサイトに届く流れです。
このページでは、D1 データベースに何が入っているか、クエリをどう書くか、マイグレーションがいつ適用されるか、適用を取り消す方法がわかります。
テーブル
サイトごとに D1 データベースが 1 つあります。すべてのテーブルは、@goodnight-web/server の packages/server/src/db/schema.ts に Drizzle ORM で定義しています。
| テーブル | 中身 |
|---|---|
submissions | 問い合わせフォームの送信内容。メールアドレス、fields(フォームの値。項目 ID をキーにした JSON)、form_version、locale、状態(unread か read)、suspected_spam、スパムスコアと理由、ip_hash(IP アドレスの HMAC。IP アドレスそのものは入れません) |
form_definitions | 問い合わせフォームの定義のバージョン。保存したバージョンごとに 1 行を追記するだけで、変更も削除もしません |
settings | retention_days、last_cleanup_at(データの自動削除用)、setup_closed_at |
user、session、account、verification、passkey | Better Auth の管理者、セッション、Passkey |
時刻は Unix ミリ秒で保存します。
管理者のログインに使う Better Auth のテーブルは、Better Auth の CLI で Drizzle アダプター用に生成しています。Better Auth もサーバーのほかの部分も、同じスキーマと同じマイグレーションの履歴を使います。
状態とスパムの疑い
問い合わせの状態と、スパムの疑いの印は、別々の列です。状態は SUBMISSION_STATUSES(['unread', 'read'])のどちらかです。
status: text('status', { enum: SUBMISSION_STATUSES })
.notNull()
.default('unread'),
/**
* Set when the honeypot was filled or the spam score reached
* `SPAM_SUSPECT_THRESHOLD`. Admins can clear or set it. Flagged rows are
* never sent to Slack and are listed apart from the others.
*/
suspectedSpam: integer('suspected_spam', { mode: 'boolean' })
.notNull()
.default(false),status 列には CHECK 制約があるので、データベースも unread と read しか受け付けません。インデックスは、未読の件数と、管理画面の「未読」「既読」のタブで使います。
// Unread count and the unread and read views.
index('idx_submissions_status_created').on(table.status, table.createdAt),
// Keep in step with SUBMISSION_STATUSES (checked by a test).
check(
'submissions_status_check',
sql`${table.status} in ('unread', 'read')`,
),問い合わせフォームの項目
フォームの項目は、テーブルの列ではありません。管理者は管理画面の「フォーム」で項目を編集します。保存するたびに、form_definitions に行が 1 つ増えます。行には、次の version 番号と、JSON の定義が入ります。
いちばん大きいバージョンが、現在のフォームです。2 つのトリガーが、このテーブルへの変更を拒否します。1 つは UPDATE 用、もう 1 つは DELETE 用です。
CREATE TRIGGER `form_definitions_no_update` BEFORE UPDATE ON `form_definitions`
BEGIN
SELECT RAISE(ABORT, 'form_definitions is append-only');
END;送信内容の値は submissions.fields に入ります。キーは f_name のような、変わらない項目 ID です。送信を確かめたときのフォームのバージョンは submissions.form_version に入ります。
古い問い合わせは、いつもそのバージョンの定義で表示します。詳しくはお問い合わせフォームの項目を変えるを参照してください。
クエリの書き方
サーバーは、入力から SQL の文字列を組み立てません。すべてのクエリは Drizzle のクエリビルダーを通ります。値は、バインドされたパラメーターとして D1 に送られます。
アプリのコードは、packages/server/src/db/database.ts の getDatabase(env.DB) を通してだけ D1 を使います。この関数は、D1 の binding につないだ Drizzle のデータベースを返します。
管理画面の一覧が典型的な例です。絞り込みの条件、1 ページの件数、オフセットは、すべてバインドします。一覧と総数は、1 回のバッチで D1 に送ります。
const orm = getDatabase(db);
const where = filterWhere(filter);
const [rows, [counted]] = await orm.batch([
orm
.select()
.from(submissions)
.where(where)
.orderBy(...orderBy(order))
.limit(limit)
.offset(offset),
orm.select({ total: count() }).from(submissions).where(where),
]);この書き方は CI で確かめます。bun run check:sql(bun run validate に含まれます)は packages/server を調べ、テスト用のヘルパー以外で次の書き方が見つかると失敗します。
export const SQL_RULES: readonly SqlRule[] = [
{
name: 'sql.raw(',
pattern: /\bsql\s*(?:\.\s*raw|\[\s*['"`]raw['"`]\s*\])\s*\(/g,
reason: 'inserts text into SQL unescaped; use the sql`` template',
},
{
name: '.prepare(',
pattern: /\.\s*prepare\s*\(/g,
reason: 'builds a D1 statement directly; use the Drizzle query builder',
},
{
name: '$client',
pattern: /\$client\b/g,
reason: 'reaches the raw D1 binding behind Drizzle',
},
];入力の確認と、D1 が受け取る SQL は、セキュリティモデルの「SQL インジェクション」を参照してください。
マイグレーションの配布と適用
マイグレーションは npm パッケージに入っていて、deploy スクリプトが新しいコードを公開する前に適用します。サイト側でマイグレーションを書く必要はありません。
適用のしくみ
サイトの wrangler.jsonc では、D1 の binding がパッケージ内のマイグレーションのディレクトリを指しています。
"d1_databases": [
{
"binding": "DB",
"migrations_dir": "node_modules/@goodnight-web/server/migrations"
}
]deploy スクリプトの中身は次のとおりです。
wrangler d1 migrations apply DB --remote && wrangler deploywrangler はファイルを名前順に適用し、適用したファイルをデータベースの d1_migrations テーブルに記録します。記録済みのファイルは飛ばすので、もう一度実行しても何も起きません。
ファイルの途中で失敗すると、D1 はそのファイルを取り消します。デプロイは wrangler deploy の前で止まります。
wrangler d1 migrations create は実行しないでください。node_modules の中にファイルを作ってしまいます。node_modules はリポジトリに入らず、パッケージを入れ直すと消えます。
スキーマの変更がサイトに届くまで
- goodnight-web がスキーマを変え、
0007_add_note.sqlのような新しいマイグレーションを生成して、@goodnight-web/serverの新しいバージョンを公開します。公開したマイグレーションのファイルは、編集も名前の変更もしません。 - Dependabot が、あなたのリポジトリに
@goodnight-web/serverを更新する PR を作ります。配布と更新のしくみを参照してください。 - あなたがマージします。
deployスクリプトが0007_add_note.sqlを適用し、それから新しいコードを公開します。
マイグレーションを適用してから wrangler deploy が終わるまでの間は、古いバージョンの Worker が新しいスキーマで動きます。スキーマの変更は追加(新しいテーブルと列)だけで、クエリは列名を指定しています。そのため、この間も古いバージョンは動き続けます。
PR のビルド(Workers Builds が本番以外のブランチもビルドする設定のとき)は deploy スクリプトを実行しないため、データベースのマイグレーションはしません。starter はプレビュー URL を無効にしているので、そのビルドにはアドレスがありません。プレビュー URL を有効にすると、プレビューは新しいマイグレーションを適用していない本番のデータベースを使います。新しいスキーマが必要な画面は、プレビューでは失敗することがあります。
Time Travel でマイグレーションを取り消す
D1 の Time Travel で、データベース全体を過去の時点(分単位)に戻せます。Workers の無料プランでは 7 日前まで、有料プランでは 30 日前までです(Cloudflare: Time Travel)。
wrangler d1 time-travel info DB --timestamp=2026-10-22T09:00:00Z
wrangler d1 time-travel restore DB --timestamp=2026-10-22T09:00:00Z復元すると、その時点より後の問い合わせとログインもすべて消えます。公開中のコードは変わりません。
マイグレーションより前の時点に戻したら、それに合う古いバージョンのサイトもデプロイしてください。そうしないと、次のデプロイでマイグレーションがもう一度適用されます。
復元すると、その時点より後に削除した行も戻ります。セキュリティのための変更も例外ではありません。
- Passkey の復旧や管理者を外す手順で削除した Passkey とセッションが戻ります。古い Passkey で、またログインできるようになります。
- 最初の Passkey を登録する前の時点に戻すと、セットアップがまた開きます。セットアップトークン(
APP_SECRET、または設定していればSETUP_TOKEN)とADMIN_EMAILSのアドレスを知っている人なら、管理者を作れる状態です。
復元した後は、passkey と session のテーブル、settings の setup_closed_at の行を確認してください。復元によって復旧や削除の手順より前に戻った場合は、その文をもう一度実行します。
Worker を前のバージョンに戻す
down マイグレーションはありません。スキーマが変わった後に Cloudflare ダッシュボードで Worker を古いバージョンに戻すと、古いコードが新しいスキーマの上で動きます。goodnight-web のスキーマ変更は追加だけ(新しいテーブルと列)なので古いコードも動きますが、Time Travel でデータベースを戻すのは、それに合うコードと一緒のときだけにしてください。
パッケージに入っているマイグレーション
パッケージには、drizzle-kit が生成した SQL のマイグレーションが入っています。
node_modules/@goodnight-web/server/migrations/
0000_initial_schema.sql
0001_settings_defaults.sql
0002_setup_closed_trigger.sql
0003_form_definitions.sql
0004_form_definition_seed.sql
0005_drop_fixed_contact_columns.sql
0006_unread_read_status.sql0003 から 0006 は、最初のリリースより前に、問い合わせの項目と状態の持ち方を変えたものです。
0003 から 0005 は、問い合わせの項目を、列からフォームの定義に移しました。
0003_form_definitions.sqlはform_definitionsを作り、submissionsにlocale、fields、form_versionを足します。0004_form_definition_seed.sqlは手で書いたマイグレーションです。次の 3 つをします。- それまでの項目(
f_name、f_company、f_phone、f_category、f_message)で、フォームのバージョン 1 を保存します。 - 既存の送信内容の
name、company、phone、category、bodyを、この ID をキーにしてfieldsに写します。 - 追記だけにするトリガーを作ります。
- それまでの項目(
0005_drop_fixed_contact_columns.sqlは、古い列を削除します。
スキーマの変更は追加だけにする決まりですが、この列の削除は例外です。最初のリリースより前に入れたので、この列に書き込む Worker はどこにもデプロイされていません。
0006_unread_read_status.sql は、状態を unread と read だけにし、suspected_spam の印を足します。SQLite では、CHECK 制約を変えるとテーブルの作り直しになります。そのため drizzle-kit は、submissions を新しいテーブルに写します。
行を写す部分は、生成された SQL を手で直し、古い状態を次のように置き換えました。
newはunreadにします。- それ以外の状態は、すべて
readにします。 heldかspamだった行には、suspected_spam = 1を付けます。