# データベース

> D1 のテーブル、クエリの書き方、マイグレーションの配布と適用、スキーマの変更がサイトに届く流れです。

このページでは、D1 データベースに何が入っているか、クエリをどう書くか、マイグレーションがいつ適用されるか、適用を取り消す方法がわかります。

## テーブル

サイトごとに D1 データベースが 1 つあります。すべてのテーブルは、`@goodnight-web/server` の `packages/server/src/db/schema.ts` に [Drizzle ORM](https://orm.drizzle.team/) で定義しています。

| テーブル                                                | 中身                                                                                                                                                                                                                                                            |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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`（[データの自動削除](https://goodnight-web.dev/ja/docs/how-it-works/data-pruning.md)用）、`setup_closed_at`                                                                                                                                             |
| `user`、`session`、`account`、`verification`、`passkey` | Better Auth の管理者、セッション、Passkey                                                                                                                                                                                                                       |

時刻は Unix ミリ秒で保存します。

管理者のログインに使う Better Auth のテーブルは、Better Auth の CLI で Drizzle アダプター用に生成しています。Better Auth もサーバーのほかの部分も、同じスキーマと同じマイグレーションの履歴を使います。

### 状態とスパムの疑い

問い合わせの状態と、スパムの疑いの印は、別々の列です。状態は `SUBMISSION_STATUSES`（`['unread', 'read']`）のどちらかです。

```ts title="packages/server/src/db/schema.ts"
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` しか受け付けません。インデックスは、未読の件数と、管理画面の「未読」「既読」のタブで使います。

```ts title="packages/server/src/db/schema.ts"
// 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` 用です。

```sql title="migrations/0004_form_definition_seed.sql"
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` に入ります。

古い問い合わせは、いつもそのバージョンの定義で表示します。詳しくは[お問い合わせフォームの項目を変える](https://goodnight-web.dev/ja/docs/contact-form.md)を参照してください。

## クエリの書き方 [#how-queries-are-written]

サーバーは、入力から SQL の文字列を組み立てません。すべてのクエリは Drizzle のクエリビルダーを通ります。値は、バインドされたパラメーターとして D1 に送られます。

アプリのコードは、`packages/server/src/db/database.ts` の `getDatabase(env.DB)` を通してだけ D1 を使います。この関数は、D1 の binding につないだ Drizzle のデータベースを返します。

管理画面の一覧が典型的な例です。絞り込みの条件、1 ページの件数、オフセットは、すべてバインドします。一覧と総数は、1 回のバッチで D1 に送ります。

```ts title="packages/server/src/features/submissions/submission-store.ts"
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` を調べ、テスト用のヘルパー以外で次の書き方が見つかると失敗します。

```ts title="scripts/check-sql.ts"
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 インジェクション」](https://goodnight-web.dev/ja/docs/security.md#sql-injection)を参照してください。

## マイグレーションの配布と適用

マイグレーションは npm パッケージに入っていて、`deploy` スクリプトが新しいコードを公開する前に適用します。サイト側でマイグレーションを書く必要はありません。

### 適用のしくみ

サイトの `wrangler.jsonc` では、D1 の binding がパッケージ内のマイグレーションのディレクトリを指しています。

```jsonc
"d1_databases": [
  {
    "binding": "DB",
    "migrations_dir": "node_modules/@goodnight-web/server/migrations"
  }
]
```

`deploy` スクリプトの中身は次のとおりです。

```sh
wrangler d1 migrations apply DB --remote && wrangler deploy
```

wrangler はファイルを名前順に適用し、適用したファイルをデータベースの `d1_migrations` テーブルに記録します。記録済みのファイルは飛ばすので、もう一度実行しても何も起きません。

ファイルの途中で失敗すると、D1 はそのファイルを取り消します。デプロイは `wrangler deploy` の前で止まります。

`wrangler d1 migrations create` は実行しないでください。`node_modules` の中にファイルを作ってしまいます。`node_modules` はリポジトリに入らず、パッケージを入れ直すと消えます。

## スキーマの変更がサイトに届くまで [#how-a-schema-change-reaches-your-site]

1. goodnight-web がスキーマを変え、`0007_add_note.sql` のような新しいマイグレーションを生成して、`@goodnight-web/server` の新しいバージョンを公開します。公開したマイグレーションのファイルは、編集も名前の変更もしません。
2. Dependabot が、あなたのリポジトリに `@goodnight-web/server` を更新する PR を作ります。[配布と更新のしくみ](https://goodnight-web.dev/ja/docs/distribution.md)を参照してください。
3. あなたがマージします。`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](https://developers.cloudflare.com/d1/reference/time-travel/)）。

```sh
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 の復旧](https://goodnight-web.dev/ja/docs/how-it-works/passkey-recovery.md)や[管理者を外す](https://goodnight-web.dev/ja/docs/security.md#removing-an-admin)手順で削除した 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 のマイグレーションが入っています。

```txt
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.sql
```

`0003` から `0006` は、最初のリリースより前に、問い合わせの項目と状態の持ち方を変えたものです。

`0003` から `0005` は、問い合わせの項目を、列からフォームの定義に移しました。

1. `0003_form_definitions.sql` は `form_definitions` を作り、`submissions` に `locale`、`fields`、`form_version` を足します。
2. `0004_form_definition_seed.sql` は手で書いたマイグレーションです。次の 3 つをします。
   - それまでの項目（`f_name`、`f_company`、`f_phone`、`f_category`、`f_message`）で、フォームのバージョン 1 を保存します。
   - 既存の送信内容の `name`、`company`、`phone`、`category`、`body` を、この ID をキーにして `fields` に写します。
   - 追記だけにするトリガーを作ります。
3. `0005_drop_fixed_contact_columns.sql` は、古い列を削除します。

[スキーマの変更は追加だけにする決まり](#how-a-schema-change-reaches-your-site)ですが、この列の削除は例外です。最初のリリースより前に入れたので、この列に書き込む Worker はどこにもデプロイされていません。

`0006_unread_read_status.sql` は、状態を `unread` と `read` だけにし、`suspected_spam` の印を足します。SQLite では、CHECK 制約を変えるとテーブルの作り直しになります。そのため drizzle-kit は、`submissions` を新しいテーブルに写します。

行を写す部分は、生成された SQL を手で直し、古い状態を次のように置き換えました。

- `new` は `unread` にします。
- それ以外の状態は、すべて `read` にします。
- `held` か `spam` だった行には、`suspected_spam = 1` を付けます。
